From 8c6ae691ef1379b62967f2eca8f60a2eef5b16e6 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Sun, 28 Dec 2025 10:12:30 +0800 Subject: [PATCH] first commit --- .claude/settings.local.json | 9 + .env.example | 9 + .gitignore | 40 ++ README.md | 146 +++++ config/config.py | 19 + main.py | 333 +++++++++++ requirements.txt | 15 + src/analysis/__init__.py | 4 + src/analysis/financial_indicators.py | 252 ++++++++ src/analysis/technical_indicators.py | 284 +++++++++ src/analysis/trading_signals.py | 461 ++++++++++++++ src/data/__init__.py | 5 + src/data/cache.py | 64 ++ src/data/database.py | 86 +++ src/data/tushare_client.py | 142 +++++ src/strategies/__init__.py | 6 + src/strategies/base_strategy.py | 45 ++ src/strategies/growth_strategy.py | 176 ++++++ src/strategies/technical_strategy.py | 519 ++++++++++++++++ src/strategies/value_strategy.py | 179 ++++++ src/utils/__init__.py | 3 + src/utils/helpers.py | 54 ++ start_web.sh | 23 + web/README.md | 108 ++++ web/app.py | 35 ++ web/requirements.txt | 2 + web/static/css/style.css | 743 +++++++++++++++++++++++ web/static/js/app.js | 862 +++++++++++++++++++++++++++ web/templates/index.html | 338 +++++++++++ 29 files changed, 4962 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/config.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 src/analysis/__init__.py create mode 100644 src/analysis/financial_indicators.py create mode 100644 src/analysis/technical_indicators.py create mode 100644 src/analysis/trading_signals.py create mode 100644 src/data/__init__.py create mode 100644 src/data/cache.py create mode 100644 src/data/database.py create mode 100644 src/data/tushare_client.py create mode 100644 src/strategies/__init__.py create mode 100644 src/strategies/base_strategy.py create mode 100644 src/strategies/growth_strategy.py create mode 100644 src/strategies/technical_strategy.py create mode 100644 src/strategies/value_strategy.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/helpers.py create mode 100755 start_web.sh create mode 100644 web/README.md create mode 100644 web/app.py create mode 100644 web/requirements.txt create mode 100644 web/static/css/style.css create mode 100644 web/static/js/app.js create mode 100644 web/templates/index.html diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6a9fa4b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(mkdir:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9848383 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +TUSHARE_TOKEN=0ed6419a00d8923dc19c0b58fc92d94c9a0696949ab91a13aa58a0cc +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_DB=0 +DATABASE_URL=sqlite:///stockagent.db +API_HOST=0.0.0.0 +API_PORT=8000 +CACHE_EXPIRE=3600 +LOG_LEVEL=INFO \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e937fd2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg + +.env +.venv +env/ +venv/ + +*.log +logs/ + +.pytest_cache/ +.coverage +htmlcov/ + +.DS_Store +*.sqlite +*.db + +.idea/ +.vscode/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e296cc0 --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# 股票分析筛选 AI Agent + +基于 tushare 数据接口的智能股票筛选和分析系统,支持多种投资策略的量化评分。 + +## 功能特性 + +- **多策略筛选**: 价值投资、成长投资、技术分析三大策略 +- **实时数据**: 基于 tushare API 获取最新股票数据 +- **智能评分**: AI 算法对股票进行综合评分和排名 +- **可视化分析**: 直观的图表展示和财务指标雷达图 +- **缓存优化**: Redis 缓存提升数据查询性能 + +## 技术栈 + +- **后端**: FastAPI + Python 3.9+ +- **数据源**: Tushare API +- **数据处理**: pandas, numpy, scikit-learn +- **缓存**: Redis +- **数据库**: SQLAlchemy (支持 SQLite/PostgreSQL) +- **前端**: Streamlit +- **可视化**: Plotly + +## 快速开始 + +### 1. 环境配置 + +```bash +# 创建虚拟环境 +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate + +# 安装依赖 +pip install -r requirements.txt + +# 配置环境变量 +cp .env.example .env +# 编辑 .env 文件,设置你的 tushare token +``` + +### 2. 配置 tushare token + +在 `.env` 文件中设置: +``` +TUSHARE_TOKEN=your_tushare_token_here +``` + +### 3. 启动服务 + +```bash +# 启动 API 服务 +python main.py + +# 启动 Web 界面 (新终端) +streamlit run src/web/streamlit_app.py +``` + +### 4. 访问应用 + +- API 文档: http://localhost:8000/docs +- Web 界面: http://localhost:8501 + +## 投资策略说明 + +### 价值投资策略 +- 关注指标: PE、PB、ROE、负债率、净利率 +- 适合: 寻找被低估的优质股票 +- 评分权重: PE(25%) + PB(20%) + ROE(20%) + 负债(15%) + 其他(20%) + +### 成长投资策略 +- 关注指标: 营收增长、利润增长、ROE、研发投入 +- 适合: 寻找高成长潜力的股票 +- 评分权重: 营收增长(30%) + 利润增长(25%) + ROE(20%) + 其他(25%) + +### 技术分析策略 +- 关注指标: 移动平均线、RSI、MACD、成交量、波林带 +- 适合: 短期交易和趋势跟踪 +- 评分权重: 趋势(30%) + 动量(25%) + 成交量(20%) + 其他(25%) + +## API 接口 + +### 获取股票列表 +```http +GET /api/stocks +``` + +### 单股分析 +```http +POST /api/analyze +Content-Type: application/json + +{ + "ts_code": "000001.SZ", + "strategy": "value" +} +``` + +### 批量筛选 +```http +POST /api/screen +Content-Type: application/json + +{ + "strategy": "value", + "min_score": 60, + "limit": 50 +} +``` + +## 项目结构 + +``` +StockAgent/ +├── src/ +│ ├── data/ # 数据获取和存储 +│ ├── analysis/ # 财务和技术指标计算 +│ ├── strategies/ # 投资策略实现 +│ ├── utils/ # 工具函数 +│ └── web/ # Web 界面 +├── config/ # 配置文件 +├── tests/ # 测试文件 +├── main.py # API 主程序 +└── requirements.txt # 依赖包 +``` + +## 使用示例 + +```python +from src.data import TushareClient +from src.strategies import ValueStrategy + +# 初始化数据客户端 +client = TushareClient() + +# 获取股票数据 +stocks = client.get_stock_list() +financial_data = client.get_financial_data('000001.SZ') + +# 使用价值投资策略评分 +strategy = ValueStrategy() +score = strategy.calculate_score(stock_data) +print(f"投资评分: {score}") +``` + +## 许可证 + +MIT License \ No newline at end of file diff --git a/config/config.py b/config/config.py new file mode 100644 index 0000000..4bd5315 --- /dev/null +++ b/config/config.py @@ -0,0 +1,19 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +class Config: + TUSHARE_TOKEN = os.getenv('TUSHARE_TOKEN') + REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') + REDIS_PORT = int(os.getenv('REDIS_PORT', 6379)) + REDIS_DB = int(os.getenv('REDIS_DB', 0)) + + DATABASE_URL = os.getenv('DATABASE_URL', 'sqlite:///stockagent.db') + + API_HOST = os.getenv('API_HOST', '0.0.0.0') + API_PORT = int(os.getenv('API_PORT', 8000)) + + CACHE_EXPIRE = int(os.getenv('CACHE_EXPIRE', 3600)) + + LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO') \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..c329dc4 --- /dev/null +++ b/main.py @@ -0,0 +1,333 @@ +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import List, Dict, Optional +import uvicorn +import json +import numpy as np + +from src.data import TushareClient, Database, RedisCache +from src.analysis import FinancialIndicators, TechnicalIndicators +from src.strategies import ValueStrategy, GrowthStrategy, TechnicalStrategy +from src.utils import setup_logger, validate_ts_code +from config.config import Config + +def format_stock_code(code: str) -> str: + """格式化股票代码,自动添加交易所后缀""" + if not code: + return code + + # 移除所有空格并转为大写 + clean_code = code.strip().replace(' ', '').upper() + + # 如果已经包含交易所后缀,直接返回 + if '.SZ' in clean_code or '.SH' in clean_code: + return clean_code + + # 只保留数字部分 + numeric_code = ''.join(filter(str.isdigit, clean_code)) + + if len(numeric_code) == 6: + # 深交所:000xxx(主板), 002xxx(中小板), 300xxx(创业板) + if numeric_code.startswith('000') or numeric_code.startswith('002') or numeric_code.startswith('300'): + return numeric_code + '.SZ' + # 上交所:600xxx, 601xxx, 603xxx(主板), 688xxx(科创板) + elif numeric_code.startswith('60') or numeric_code.startswith('688'): + return numeric_code + '.SH' + + # 默认返回原始代码加.SZ + return numeric_code + '.SZ' if numeric_code else code + +def clean_data_for_json(data): + """清理数据,确保JSON兼容性""" + if isinstance(data, dict): + return {k: clean_data_for_json(v) for k, v in data.items()} + elif isinstance(data, list): + return [clean_data_for_json(item) for item in data] + elif isinstance(data, (int, str, bool)) or data is None: + return data + elif isinstance(data, float): + if np.isnan(data) or np.isinf(data): + return 0 + return data + else: + return str(data) + +app = FastAPI(title="Stock Agent API", description="优质股票分析筛选 AI Agent", version="1.0.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +logger = setup_logger(__name__) +tushare_client = TushareClient() +database = Database() +cache = RedisCache() + +class AnalysisRequest(BaseModel): + ts_code: str + strategy: str = "value" + +class ScreenRequest(BaseModel): + strategy: str = "value" + min_score: float = 60 + limit: int = 50 + +@app.get("/") +async def root(): + return {"message": "Stock Agent API is running"} + +@app.get("/api/stocks") +async def get_stock_list(): + try: + cache_key = "stock_list" + cached_data = cache.get(cache_key) + + if cached_data: + return cached_data + + stocks = tushare_client.get_stock_list() + if stocks.empty: + raise HTTPException(status_code=404, detail="No stock data found") + + stock_list = stocks.to_dict('records') + cache.set(cache_key, stock_list, 3600) + + return {"data": stock_list, "count": len(stock_list)} + + except Exception as e: + logger.error(f"Error getting stock list: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/api/analyze") +async def analyze_stock(request: AnalysisRequest): + try: + # 格式化股票代码 + formatted_ts_code = format_stock_code(request.ts_code) + + if not validate_ts_code(formatted_ts_code): + raise HTTPException(status_code=400, detail="Invalid stock code") + + cache_key = f"analysis_{formatted_ts_code}_{request.strategy}" + cached_data = cache.get(cache_key) + + if cached_data: + return cached_data + + # 获取股票基本信息 + stock_basic_info = tushare_client.get_stock_basic(formatted_ts_code) + if not stock_basic_info: + raise HTTPException(status_code=404, detail="Stock not found") + + daily_data = tushare_client.get_stock_daily(formatted_ts_code) + if daily_data.empty: + raise HTTPException(status_code=404, detail="No daily data found") + + financial_data = tushare_client.get_financial_data(formatted_ts_code) + + financial_ratios = FinancialIndicators.calculate_all_ratios(financial_data) + + daily_with_indicators = TechnicalIndicators.calculate_all_indicators(daily_data) + + stock_data = { + 'ts_code': formatted_ts_code, + 'financial_ratios': financial_ratios, + 'technical_indicators': daily_with_indicators.iloc[-1].to_dict() if not daily_with_indicators.empty else {} + } + + if request.strategy == "value": + strategy = ValueStrategy() + elif request.strategy == "growth": + strategy = GrowthStrategy() + elif request.strategy == "technical": + strategy = TechnicalStrategy() + else: + raise HTTPException(status_code=400, detail="Invalid strategy") + + score = strategy.calculate_score(stock_data) + + # 如果是技术策略,添加交易信号分析 + trading_signals = None + if request.strategy == "technical": + trading_signals = strategy.get_trading_signals(stock_data) + + result = { + 'ts_code': request.ts_code, + 'strategy': request.strategy, + 'score': round(score, 2), + 'financial_ratios': clean_data_for_json(financial_ratios), + 'recommendation': 'BUY' if score >= 65 else 'HOLD' if score >= 45 else 'SELL', + # 添加股票基本信息 + 'name': stock_basic_info['name'], + 'industry': stock_basic_info['industry'], + 'current_price': clean_data_for_json(stock_basic_info['current_price']), + 'market_cap': clean_data_for_json(stock_basic_info['market_cap']), + 'list_date': stock_basic_info['list_date'] + } + + # 添加交易信号(如果是技术分析) + if trading_signals: + result['trading_signals'] = clean_data_for_json(trading_signals) + + # 确保整个结果都是JSON兼容的 + result = clean_data_for_json(result) + + cache.set(cache_key, result, 1800) + + return result + + except Exception as e: + logger.error(f"Error analyzing stock {request.ts_code}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/api/screen") +async def screen_stocks(request: ScreenRequest): + import uuid + request_id = str(uuid.uuid4())[:8] + + try: + logger.info(f"[{request_id}] Starting screening request with strategy={request.strategy}") + + cache_key = f"screen_hs300_{request.strategy}_{request.min_score}_{request.limit}" + cached_data = cache.get(cache_key) + + if cached_data: + logger.info(f"[{request_id}] Returning cached data") + return cached_data + + # 获取沪深300成分股 + hs300_stocks = tushare_client.get_hs300_stocks() + if hs300_stocks.empty: + raise HTTPException(status_code=404, detail="No HS300 stock data found") + + # 限制处理数量,避免超时(先处理前300只进行测试) + max_process = min(300, len(hs300_stocks)) + hs300_stocks = hs300_stocks.head(max_process) + logger.info(f"Limited processing to {max_process} stocks to avoid timeout") + + # 初始化三种策略 + value_strategy = ValueStrategy() + growth_strategy = GrowthStrategy() + technical_strategy = TechnicalStrategy() + + results = [] + processed_count = 0 + + if request.strategy == "comprehensive": + logger.info(f"[{request_id}] Starting comprehensive screening of {len(hs300_stocks)} HS300 stocks") + else: + # 保持向后兼容,支持单一策略 + if request.strategy == "value": + strategy = value_strategy + elif request.strategy == "growth": + strategy = growth_strategy + elif request.strategy == "technical": + strategy = technical_strategy + else: + raise HTTPException(status_code=400, detail="Invalid strategy") + logger.info(f"[{request_id}] Starting to screen {len(hs300_stocks)} HS300 stocks with {request.strategy} strategy") + + # 处理所有沪深300成分股 + for _, stock in hs300_stocks.iterrows(): + try: + ts_code = stock['ts_code'] + logger.debug(f"Processing stock: {ts_code}") # 添加调试日志 + + financial_data = tushare_client.get_financial_data(ts_code) + financial_ratios = FinancialIndicators.calculate_all_ratios(financial_data) + + daily_data = tushare_client.get_stock_daily(ts_code) + if not daily_data.empty: + daily_with_indicators = TechnicalIndicators.calculate_all_indicators(daily_data) + technical_indicators = daily_with_indicators.iloc[-1].to_dict() + else: + technical_indicators = {} + + stock_data = { + 'ts_code': ts_code, + 'name': stock['name'], + 'industry': stock.get('industry', ''), + 'financial_ratios': financial_ratios, + 'technical_indicators': technical_indicators + } + + if request.strategy == "comprehensive": + # 综合评估:计算三种策略的平均分 + value_score = value_strategy.calculate_score(stock_data) + growth_score = growth_strategy.calculate_score(stock_data) + technical_score = technical_strategy.calculate_score(stock_data) + + # 综合评分(可以调整权重) + comprehensive_score = (value_score * 0.4 + growth_score * 0.3 + technical_score * 0.3) + + if comprehensive_score >= request.min_score: + stock_data['score'] = round(comprehensive_score, 2) + stock_data['value_score'] = round(value_score, 2) + stock_data['growth_score'] = round(growth_score, 2) + stock_data['technical_score'] = round(technical_score, 2) + + # 综合推荐逻辑 + high_scores = sum([score >= 70 for score in [value_score, growth_score, technical_score]]) + if comprehensive_score >= 75 or high_scores >= 2: + stock_data['recommendation'] = 'BUY' + elif comprehensive_score >= 65: + stock_data['recommendation'] = 'HOLD' + else: + stock_data['recommendation'] = 'WATCH' + + logger.debug(f"Adding stock {ts_code} with comprehensive score {comprehensive_score}") + results.append(stock_data) + else: + # 单一策略评估(保持原有逻辑) + score = strategy.calculate_score(stock_data) + + if score >= request.min_score: + stock_data['score'] = round(score, 2) + stock_data['recommendation'] = 'BUY' if score >= 65 else 'HOLD' + logger.debug(f"Adding stock {ts_code} with {request.strategy} score {score}") + results.append(stock_data) + + processed_count += 1 + + if len(results) >= request.limit: + logger.info(f"[{request_id}] Reached limit of {request.limit} results, stopping processing") + break + + except Exception as e: + logger.error(f"Error processing stock {stock.get('ts_code', 'unknown')}: {e}") + continue + + # 对结果进行排序 + results = sorted(results, key=lambda x: x['score'], reverse=True) + + logger.info(f"[{request_id}] Screening completed. Found {len(results)} qualifying stocks from {processed_count} processed") + + response = { + 'strategy': request.strategy, + 'min_score': request.min_score, + 'results': clean_data_for_json(results), + 'count': len(results), + 'processed_count': processed_count, + 'request_id': request_id # 添加请求ID到响应中 + } + + cache.set(cache_key, response, 1800) + + logger.info(f"[{request_id}] Returning {len(results)} results") + return response + + except Exception as e: + logger.error(f"[{request_id}] Error screening stocks: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +if __name__ == "__main__": + uvicorn.run( + "main:app", + host=Config.API_HOST, + port=Config.API_PORT, + reload=True + ) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..402551d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +pandas>=2.0.0 +numpy>=1.24.0 +tushare==1.3.3 +redis==5.0.1 +sqlalchemy==2.0.23 +psycopg2-binary==2.9.9 +scikit-learn>=1.3.0 +requests==2.31.0 +python-dotenv==1.0.0 +pydantic==2.5.0 +pytest==7.4.3 +black==23.11.0 +Flask==2.3.3 \ No newline at end of file diff --git a/src/analysis/__init__.py b/src/analysis/__init__.py new file mode 100644 index 0000000..d3343e1 --- /dev/null +++ b/src/analysis/__init__.py @@ -0,0 +1,4 @@ +from .financial_indicators import FinancialIndicators +from .technical_indicators import TechnicalIndicators + +__all__ = ['FinancialIndicators', 'TechnicalIndicators'] \ No newline at end of file diff --git a/src/analysis/financial_indicators.py b/src/analysis/financial_indicators.py new file mode 100644 index 0000000..436ba4b --- /dev/null +++ b/src/analysis/financial_indicators.py @@ -0,0 +1,252 @@ +import pandas as pd +import numpy as np +from typing import Dict, Optional + +class FinancialIndicators: + + @staticmethod + def safe_divide(numerator, denominator, default=0): + """安全除法,避免NaN值""" + if denominator is None or denominator == 0 or numerator is None: + return default + try: + result = float(numerator) / float(denominator) + if np.isnan(result) or np.isinf(result): + return default + return result + except (TypeError, ZeroDivisionError): + return default + + @staticmethod + def safe_value(value, default=0): + """安全值处理,避免NaN和Inf""" + if value is None: + return default + try: + value = float(value) + if np.isnan(value) or np.isinf(value): + return default + return value + except (TypeError, ValueError): + return default + + @staticmethod + def calculate_roe(net_income: float, shareholders_equity: float) -> float: + return FinancialIndicators.safe_divide(net_income, shareholders_equity) * 100 + + @staticmethod + def calculate_roa(net_income: float, total_assets: float) -> float: + return FinancialIndicators.safe_divide(net_income, total_assets) * 100 + + @staticmethod + def calculate_debt_ratio(total_debt: float, total_assets: float) -> float: + return FinancialIndicators.safe_divide(total_debt, total_assets) * 100 + + @staticmethod + def calculate_current_ratio(current_assets: float, current_liabilities: float) -> float: + return FinancialIndicators.safe_divide(current_assets, current_liabilities) + + @staticmethod + def calculate_gross_margin(gross_profit: float, revenue: float) -> float: + return FinancialIndicators.safe_divide(gross_profit, revenue) * 100 + + @staticmethod + def calculate_net_margin(net_income: float, revenue: float) -> float: + return FinancialIndicators.safe_divide(net_income, revenue) * 100 + + @staticmethod + def calculate_pe_ratio(price: float, eps: float) -> float: + return FinancialIndicators.safe_divide(price, eps, default=0) + + @staticmethod + def calculate_pb_ratio(price: float, book_value_per_share: float) -> float: + return FinancialIndicators.safe_divide(price, book_value_per_share, default=0) + + @staticmethod + def calculate_roic(nopat: float, invested_capital: float) -> float: + """投入资本回报率 ROIC""" + return FinancialIndicators.safe_divide(nopat, invested_capital) * 100 + + @staticmethod + def calculate_asset_turnover(revenue: float, total_assets: float) -> float: + """总资产周转率""" + return FinancialIndicators.safe_divide(revenue, total_assets) + + @staticmethod + def calculate_inventory_turnover(cogs: float, inventory: float) -> float: + """存货周转率""" + return FinancialIndicators.safe_divide(cogs, inventory) + + @staticmethod + def calculate_receivable_turnover(revenue: float, receivables: float) -> float: + """应收账款周转率""" + return FinancialIndicators.safe_divide(revenue, receivables) + + @staticmethod + def calculate_working_capital_ratio(working_capital: float, revenue: float) -> float: + """营运资金比率""" + return FinancialIndicators.safe_divide(working_capital, revenue) * 100 + + @staticmethod + def calculate_ocf_ratio(operating_cash_flow: float, net_income: float) -> float: + """经营现金流比率""" + return FinancialIndicators.safe_divide(operating_cash_flow, net_income) * 100 + + @staticmethod + def calculate_revenue_growth(current_revenue: float, previous_revenue: float) -> float: + """营收增长率""" + if previous_revenue is None or previous_revenue == 0: + return 0 + growth = FinancialIndicators.safe_divide(current_revenue - previous_revenue, previous_revenue) * 100 + return FinancialIndicators.safe_value(growth) + + @staticmethod + def calculate_profit_growth(current_profit: float, previous_profit: float) -> float: + """利润增长率""" + if previous_profit is None or previous_profit == 0: + return 0 + growth = FinancialIndicators.safe_divide(current_profit - previous_profit, previous_profit) * 100 + return FinancialIndicators.safe_value(growth) + + @staticmethod + def calculate_dupont_roe(net_margin: float, asset_turnover: float, equity_multiplier: float) -> float: + """杜邦分析ROE = 净利率 × 总资产周转率 × 权益乘数""" + net_margin = FinancialIndicators.safe_value(net_margin) + asset_turnover = FinancialIndicators.safe_value(asset_turnover) + equity_multiplier = FinancialIndicators.safe_value(equity_multiplier) + return net_margin * asset_turnover * equity_multiplier + + @staticmethod + def calculate_peg_ratio(pe_ratio: float, growth_rate: float) -> float: + if growth_rate is None or growth_rate <= 0: + return 0 + return FinancialIndicators.safe_divide(pe_ratio, growth_rate, default=0) + + @classmethod + def calculate_all_ratios(cls, financial_data: Dict) -> Dict: + try: + ratios = {} + + # Helper function to safely get values from DataFrame + def safe_get(obj, key, default=0): + value = obj.get(key, default) + return cls.safe_value(value, default) + + # 获取当前期和上一期的数据用于计算增长率 + income_current = None + income_previous = None + balance_current = None + cashflow_current = None + + if 'income' in financial_data and not financial_data['income'].empty: + income_current = financial_data['income'].iloc[0] if len(financial_data['income']) > 0 else None + income_previous = financial_data['income'].iloc[1] if len(financial_data['income']) > 1 else None + + if 'balance' in financial_data and not financial_data['balance'].empty: + balance_current = financial_data['balance'].iloc[0] + + if 'cashflow' in financial_data and not financial_data['cashflow'].empty: + cashflow_current = financial_data['cashflow'].iloc[0] + + # 基础盈利能力指标 + if income_current is not None: + revenue = safe_get(income_current, 'revenue') + operate_cost = safe_get(income_current, 'operate_cost') + net_income = safe_get(income_current, 'n_income') + + ratios['gross_margin'] = cls.calculate_gross_margin( + revenue - operate_cost, + revenue + ) + ratios['net_margin'] = cls.calculate_net_margin( + net_income, + revenue + ) + + # 增长率指标 + if income_previous is not None: + prev_revenue = safe_get(income_previous, 'revenue') + prev_net_income = safe_get(income_previous, 'n_income') + + ratios['revenue_growth'] = cls.calculate_revenue_growth(revenue, prev_revenue) + ratios['profit_growth'] = cls.calculate_profit_growth(net_income, prev_net_income) + else: + ratios['revenue_growth'] = 0 + ratios['profit_growth'] = 0 + + # 财务健康度和效率指标 + if balance_current is not None: + total_liab = safe_get(balance_current, 'total_liab') + total_assets = safe_get(balance_current, 'total_assets') + total_cur_assets = safe_get(balance_current, 'total_cur_assets') + total_cur_liab = safe_get(balance_current, 'total_cur_liab') + shareholders_equity = safe_get(balance_current, 'total_hldr_eqy_exc_min_int') + inventories = safe_get(balance_current, 'inventories') + accounts_receiv = safe_get(balance_current, 'accounts_receiv') + + ratios['debt_ratio'] = cls.calculate_debt_ratio(total_liab, total_assets) + ratios['current_ratio'] = cls.calculate_current_ratio(total_cur_assets, total_cur_liab) + + # 营运资金 + working_capital = total_cur_assets - total_cur_liab + if income_current is not None: + revenue = safe_get(income_current, 'revenue') + ratios['working_capital_ratio'] = cls.calculate_working_capital_ratio(working_capital, revenue) + ratios['asset_turnover'] = cls.calculate_asset_turnover(revenue, total_assets) + ratios['receivable_turnover'] = cls.calculate_receivable_turnover(revenue, accounts_receiv) + + # 存货周转率 + operate_cost = safe_get(income_current, 'operate_cost') + ratios['inventory_turnover'] = cls.calculate_inventory_turnover(operate_cost, inventories) + else: + ratios['working_capital_ratio'] = 0 + ratios['asset_turnover'] = 0 + ratios['receivable_turnover'] = 0 + ratios['inventory_turnover'] = 0 + + # ROE和ROA + if income_current is not None: + net_income = safe_get(income_current, 'n_income') + ratios['roe'] = cls.calculate_roe(net_income, shareholders_equity) + ratios['roa'] = cls.calculate_roa(net_income, total_assets) + + # 权益乘数(杜邦分析组成部分) + equity_multiplier = cls.safe_divide(total_assets, shareholders_equity, default=1) + ratios['equity_multiplier'] = equity_multiplier + + # 杜邦ROE分解 + if 'net_margin' in ratios and 'asset_turnover' in ratios: + ratios['dupont_roe'] = cls.calculate_dupont_roe( + ratios['net_margin'] / 100, # 转换为小数 + ratios['asset_turnover'], + equity_multiplier + ) + else: + ratios['dupont_roe'] = 0 + else: + ratios['roe'] = 0 + ratios['roa'] = 0 + ratios['equity_multiplier'] = 1 + ratios['dupont_roe'] = 0 + + # 现金流指标 + if cashflow_current is not None and income_current is not None: + operating_cash_flow = safe_get(cashflow_current, 'n_cashflow_act') + net_income = safe_get(income_current, 'n_income') + + ratios['ocf_ratio'] = cls.calculate_ocf_ratio(operating_cash_flow, net_income) + ratios['operating_cash_flow'] = operating_cash_flow + else: + ratios['ocf_ratio'] = 0 + ratios['operating_cash_flow'] = 0 + + # 确保所有值都是有效的 + clean_ratios = {} + for key, value in ratios.items(): + clean_ratios[key] = cls.safe_value(value, default=0) + + return clean_ratios + + except Exception as e: + print(f"Error calculating financial ratios: {e}") + return {} \ No newline at end of file diff --git a/src/analysis/technical_indicators.py b/src/analysis/technical_indicators.py new file mode 100644 index 0000000..21e0aa4 --- /dev/null +++ b/src/analysis/technical_indicators.py @@ -0,0 +1,284 @@ +import pandas as pd +import numpy as np +from typing import Dict, Optional + +class TechnicalIndicators: + + @staticmethod + def safe_value(value, default=0): + """安全值处理,避免NaN和Inf""" + if value is None: + return default + try: + value = float(value) + if np.isnan(value) or np.isinf(value): + return default + return value + except (TypeError, ValueError): + return default + + @staticmethod + def calculate_sma(data: pd.Series, window: int) -> pd.Series: + return data.rolling(window=window).mean() + + @staticmethod + def calculate_ema(data: pd.Series, window: int) -> pd.Series: + return data.ewm(span=window, adjust=False).mean() + + @staticmethod + def calculate_rsi(data: pd.Series, window: int = 14) -> pd.Series: + delta = data.diff() + gain = (delta.where(delta > 0, 0)).rolling(window=window).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=window).mean() + rs = gain / loss + rsi = 100 - (100 / (1 + rs)) + return rsi + + @staticmethod + def calculate_macd(data: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9) -> Dict[str, pd.Series]: + ema_fast = data.ewm(span=fast).mean() + ema_slow = data.ewm(span=slow).mean() + macd_line = ema_fast - ema_slow + signal_line = macd_line.ewm(span=signal).mean() + histogram = macd_line - signal_line + + return { + 'macd': macd_line, + 'signal': signal_line, + 'histogram': histogram + } + + @staticmethod + def calculate_bollinger_bands(data: pd.Series, window: int = 20, std_dev: int = 2) -> Dict[str, pd.Series]: + sma = data.rolling(window=window).mean() + std = data.rolling(window=window).std() + upper_band = sma + (std * std_dev) + lower_band = sma - (std * std_dev) + + return { + 'upper': upper_band, + 'middle': sma, + 'lower': lower_band + } + + @staticmethod + def calculate_volume_sma(volume: pd.Series, window: int = 20) -> pd.Series: + return volume.rolling(window=window).mean() + + @staticmethod + def calculate_price_change(data: pd.Series, periods: int = 1) -> pd.Series: + return data.pct_change(periods) * 100 + + @staticmethod + def calculate_stochastic_oscillator(high: pd.Series, low: pd.Series, close: pd.Series, k_window: int = 14, d_window: int = 3) -> Dict[str, pd.Series]: + """随机震荡器 KD指标""" + lowest_low = low.rolling(window=k_window).min() + highest_high = high.rolling(window=k_window).max() + k_percent = 100 * ((close - lowest_low) / (highest_high - lowest_low)) + d_percent = k_percent.rolling(window=d_window).mean() + + return { + 'stoch_k': k_percent, + 'stoch_d': d_percent + } + + @staticmethod + def calculate_williams_r(high: pd.Series, low: pd.Series, close: pd.Series, window: int = 14) -> pd.Series: + """威廉指标 %R""" + highest_high = high.rolling(window=window).max() + lowest_low = low.rolling(window=window).min() + williams_r = -100 * ((highest_high - close) / (highest_high - lowest_low)) + return williams_r + + @staticmethod + def calculate_cci(high: pd.Series, low: pd.Series, close: pd.Series, window: int = 20) -> pd.Series: + """顺势指标 CCI""" + typical_price = (high + low + close) / 3 + sma_tp = typical_price.rolling(window=window).mean() + mean_deviation = typical_price.rolling(window=window).apply(lambda x: np.fabs(x - x.mean()).mean()) + cci = (typical_price - sma_tp) / (0.015 * mean_deviation) + return cci + + @staticmethod + def calculate_atr(high: pd.Series, low: pd.Series, close: pd.Series, window: int = 14) -> pd.Series: + """真实波动幅度均值 ATR""" + high_low = high - low + high_close = np.abs(high - close.shift()) + low_close = np.abs(low - close.shift()) + true_range = np.maximum(high_low, np.maximum(high_close, low_close)) + atr = true_range.rolling(window=window).mean() + return atr + + @staticmethod + def calculate_adx(high: pd.Series, low: pd.Series, close: pd.Series, window: int = 14) -> Dict[str, pd.Series]: + """趋势强度指标 ADX""" + # 计算+DI和-DI + high_diff = high.diff() + low_diff = low.diff() + + plus_dm = np.where((high_diff > low_diff) & (high_diff > 0), high_diff, 0) + minus_dm = np.where((low_diff > high_diff) & (low_diff > 0), low_diff, 0) + + plus_dm = pd.Series(plus_dm, index=high.index) + minus_dm = pd.Series(minus_dm, index=low.index) + + # 计算真实波动幅度 + tr = TechnicalIndicators.calculate_atr(high, low, close, 1) + + # 计算DI + plus_di = 100 * (plus_dm.rolling(window=window).mean() / tr.rolling(window=window).mean()) + minus_di = 100 * (minus_dm.rolling(window=window).mean() / tr.rolling(window=window).mean()) + + # 计算ADX + dx = 100 * np.abs((plus_di - minus_di) / (plus_di + minus_di)) + adx = dx.rolling(window=window).mean() + + return { + 'adx': adx, + 'plus_di': plus_di, + 'minus_di': minus_di + } + + @staticmethod + def calculate_momentum(data: pd.Series, window: int = 12) -> pd.Series: + """价格动量指标""" + return data.diff(window) + + @staticmethod + def calculate_rate_of_change(data: pd.Series, window: int = 12) -> pd.Series: + """变化率指标 ROC""" + return ((data - data.shift(window)) / data.shift(window)) * 100 + + @staticmethod + def calculate_obv(close: pd.Series, volume: pd.Series) -> pd.Series: + """能量潮指标 OBV""" + obv = np.zeros(len(close)) + obv[0] = volume.iloc[0] + + for i in range(1, len(close)): + if close.iloc[i] > close.iloc[i-1]: + obv[i] = obv[i-1] + volume.iloc[i] + elif close.iloc[i] < close.iloc[i-1]: + obv[i] = obv[i-1] - volume.iloc[i] + else: + obv[i] = obv[i-1] + + return pd.Series(obv, index=close.index) + + @staticmethod + def calculate_volume_ratio(volume: pd.Series, window: int = 20) -> pd.Series: + """量比指标""" + volume_ma = volume.rolling(window=window).mean() + return volume / volume_ma + + @staticmethod + def calculate_price_volume_trend(close: pd.Series, volume: pd.Series) -> pd.Series: + """价量趋势指标 PVT""" + price_change_pct = close.pct_change() + pvt = (price_change_pct * volume).cumsum() + return pvt + + @staticmethod + def calculate_volatility(data: pd.Series, window: int = 20) -> pd.Series: + returns = data.pct_change() + return returns.rolling(window=window).std() * np.sqrt(252) * 100 + + @classmethod + def calculate_all_indicators(cls, df: pd.DataFrame) -> pd.DataFrame: + result = df.copy() + close_price = df['close'] + high_price = df.get('high', close_price) + low_price = df.get('low', close_price) + volume = df.get('vol', pd.Series()) + + try: + # 移动平均线系统 + result['sma_20'] = cls.calculate_sma(close_price, 20).fillna(0) + result['sma_50'] = cls.calculate_sma(close_price, 50).fillna(0) + result['sma_200'] = cls.calculate_sma(close_price, 200).fillna(0) + result['ema_12'] = cls.calculate_ema(close_price, 12).fillna(0) + result['ema_26'] = cls.calculate_ema(close_price, 26).fillna(0) + + # 动量和震荡指标 + result['rsi'] = cls.calculate_rsi(close_price).fillna(50) + result['momentum'] = cls.calculate_momentum(close_price).fillna(0) + result['roc'] = cls.calculate_rate_of_change(close_price).fillna(0) + + # MACD系统 + macd_data = cls.calculate_macd(close_price) + result['macd'] = macd_data['macd'].fillna(0) + result['macd_signal'] = macd_data['signal'].fillna(0) + result['macd_histogram'] = macd_data['histogram'].fillna(0) + + # KD随机指标 + if 'high' in df.columns and 'low' in df.columns: + stoch_data = cls.calculate_stochastic_oscillator(high_price, low_price, close_price) + result['stoch_k'] = stoch_data['stoch_k'].fillna(50) + result['stoch_d'] = stoch_data['stoch_d'].fillna(50) + + # 威廉指标 + result['williams_r'] = cls.calculate_williams_r(high_price, low_price, close_price).fillna(-50) + + # CCI顺势指标 + result['cci'] = cls.calculate_cci(high_price, low_price, close_price).fillna(0) + + # ATR真实波动幅度 + result['atr'] = cls.calculate_atr(high_price, low_price, close_price).fillna(0) + + # ADX趋势强度 + adx_data = cls.calculate_adx(high_price, low_price, close_price) + result['adx'] = adx_data['adx'].fillna(25) + result['plus_di'] = adx_data['plus_di'].fillna(20) + result['minus_di'] = adx_data['minus_di'].fillna(20) + else: + # 填充默认值 + result['stoch_k'] = 50 + result['stoch_d'] = 50 + result['williams_r'] = -50 + result['cci'] = 0 + result['atr'] = 0 + result['adx'] = 25 + result['plus_di'] = 20 + result['minus_di'] = 20 + + # 布林带系统 + bollinger = cls.calculate_bollinger_bands(close_price) + result['bb_upper'] = bollinger['upper'].fillna(close_price) + result['bb_middle'] = bollinger['middle'].fillna(close_price) + result['bb_lower'] = bollinger['lower'].fillna(close_price) + + # 布林带宽度(量化波动性) + bb_width = (result['bb_upper'] - result['bb_lower']) / result['bb_middle'] * 100 + result['bb_width'] = bb_width.fillna(0) + + # 价格变化和波动率 + result['price_change'] = cls.calculate_price_change(close_price).fillna(0) + result['volatility'] = cls.calculate_volatility(close_price).fillna(20) + + # 成交量指标 + if not volume.empty: + result['volume_sma'] = cls.calculate_volume_sma(volume).fillna(volume.mean()) + result['volume_ratio'] = cls.calculate_volume_ratio(volume).fillna(1) + result['obv'] = cls.calculate_obv(close_price, volume).fillna(0) + result['pvt'] = cls.calculate_price_volume_trend(close_price, volume).fillna(0) + result['current_volume'] = volume + else: + result['volume_sma'] = 0 + result['volume_ratio'] = 1 + result['obv'] = 0 + result['pvt'] = 0 + result['current_volume'] = 0 + + # 添加当前价格到技术指标中(用于策略计算) + result['current_price'] = close_price + + # 确保所有数值都是安全的 + for col in result.columns: + if result[col].dtype in ['float64', 'float32']: + result[col] = result[col].replace([np.inf, -np.inf], 0).fillna(0) + + return result + + except Exception as e: + print(f"Error calculating technical indicators: {e}") + return result \ No newline at end of file diff --git a/src/analysis/trading_signals.py b/src/analysis/trading_signals.py new file mode 100644 index 0000000..f69b02d --- /dev/null +++ b/src/analysis/trading_signals.py @@ -0,0 +1,461 @@ +import pandas as pd +import numpy as np +from typing import Dict, List, Optional, Tuple +from .technical_indicators import TechnicalIndicators + +class TradingSignalsAnalyzer: + """技术分析交易信号分析器 - 专门为A股市场设计""" + + def __init__(self): + self.signals = {} + + @staticmethod + def safe_value(value, default=0): + """安全值处理""" + if value is None or pd.isna(value): + return default + try: + value = float(value) + if np.isnan(value) or np.isinf(value): + return default + return value + except (TypeError, ValueError): + return default + + def analyze_entry_signals(self, technical_data: Dict) -> Dict: + """分析买入信号""" + signals = { + 'overall_signal': 'NEUTRAL', + 'signal_strength': 0, # 0-100 + 'entry_reasons': [], + 'warning_flags': [], + 'confidence_level': 'LOW' # LOW/MEDIUM/HIGH + } + + signal_count = 0 + total_signals = 0 + + # 1. 趋势突破信号 + trend_signal = self._analyze_trend_breakthrough(technical_data) + if trend_signal['signal'] == 'BUY': + signal_count += trend_signal['weight'] + signals['entry_reasons'].append(trend_signal['reason']) + total_signals += trend_signal['weight'] + + # 2. 动量确认信号 + momentum_signal = self._analyze_momentum_confirmation(technical_data) + if momentum_signal['signal'] == 'BUY': + signal_count += momentum_signal['weight'] + signals['entry_reasons'].append(momentum_signal['reason']) + total_signals += momentum_signal['weight'] + + # 3. 成交量确认 + volume_signal = self._analyze_volume_confirmation(technical_data) + if volume_signal['signal'] == 'BUY': + signal_count += volume_signal['weight'] + signals['entry_reasons'].append(volume_signal['reason']) + elif volume_signal['signal'] == 'WARNING': + signals['warning_flags'].append(volume_signal['reason']) + total_signals += volume_signal['weight'] + + # 4. 支撑阻力位分析 + support_signal = self._analyze_support_levels(technical_data) + if support_signal['signal'] == 'BUY': + signal_count += support_signal['weight'] + signals['entry_reasons'].append(support_signal['reason']) + total_signals += support_signal['weight'] + + # 5. 超卖反弹机会 + oversold_signal = self._analyze_oversold_opportunity(technical_data) + if oversold_signal['signal'] == 'BUY': + signal_count += oversold_signal['weight'] + signals['entry_reasons'].append(oversold_signal['reason']) + total_signals += oversold_signal['weight'] + + # 计算总体信号强度 + if total_signals > 0: + signals['signal_strength'] = int((signal_count / total_signals) * 100) + + # 确定总体信号 + if signals['signal_strength'] >= 75: + signals['overall_signal'] = 'STRONG_BUY' + signals['confidence_level'] = 'HIGH' + elif signals['signal_strength'] >= 60: + signals['overall_signal'] = 'BUY' + signals['confidence_level'] = 'MEDIUM' + elif signals['signal_strength'] >= 40: + signals['overall_signal'] = 'WEAK_BUY' + signals['confidence_level'] = 'LOW' + else: + signals['overall_signal'] = 'NEUTRAL' + + return signals + + def analyze_exit_signals(self, technical_data: Dict) -> Dict: + """分析卖出信号""" + signals = { + 'overall_signal': 'HOLD', + 'signal_strength': 0, + 'exit_reasons': [], + 'risk_flags': [], + 'confidence_level': 'LOW' + } + + signal_count = 0 + total_signals = 0 + + # 1. 趋势破坏信号 + trend_break = self._analyze_trend_breakdown(technical_data) + if trend_break['signal'] == 'SELL': + signal_count += trend_break['weight'] + signals['exit_reasons'].append(trend_break['reason']) + total_signals += trend_break['weight'] + + # 2. 动量转弱信号 + momentum_weak = self._analyze_momentum_weakness(technical_data) + if momentum_weak['signal'] == 'SELL': + signal_count += momentum_weak['weight'] + signals['exit_reasons'].append(momentum_weak['reason']) + total_signals += momentum_weak['weight'] + + # 3. 超买风险 + overbought_risk = self._analyze_overbought_risk(technical_data) + if overbought_risk['signal'] == 'SELL': + signal_count += overbought_risk['weight'] + signals['exit_reasons'].append(overbought_risk['reason']) + total_signals += overbought_risk['weight'] + + # 4. 成交量背离 + volume_divergence = self._analyze_volume_divergence(technical_data) + if volume_divergence['signal'] == 'SELL': + signal_count += volume_divergence['weight'] + signals['risk_flags'].append(volume_divergence['reason']) + total_signals += volume_divergence['weight'] + + # 计算信号强度 + if total_signals > 0: + signals['signal_strength'] = int((signal_count / total_signals) * 100) + + # 确定卖出信号 + if signals['signal_strength'] >= 75: + signals['overall_signal'] = 'STRONG_SELL' + signals['confidence_level'] = 'HIGH' + elif signals['signal_strength'] >= 60: + signals['overall_signal'] = 'SELL' + signals['confidence_level'] = 'MEDIUM' + elif signals['signal_strength'] >= 40: + signals['overall_signal'] = 'WEAK_SELL' + signals['confidence_level'] = 'LOW' + + return signals + + def calculate_stop_loss_take_profit(self, technical_data: Dict, entry_price: float = None) -> Dict: + """计算止盈止损位""" + current_price = self.safe_value(technical_data.get('current_price', 0)) + if entry_price is None: + entry_price = current_price + + atr = self.safe_value(technical_data.get('atr', 0)) + bb_upper = self.safe_value(technical_data.get('bb_upper', 0)) + bb_lower = self.safe_value(technical_data.get('bb_lower', 0)) + sma_20 = self.safe_value(technical_data.get('sma_20', 0)) + volatility = self.safe_value(technical_data.get('volatility', 20)) + + # A股市场特点:10%涨跌停板限制 + max_daily_change = 0.10 + + # 1. ATR动态止损法 + atr_stop_loss = entry_price - (atr * 2) if atr > 0 else entry_price * 0.92 + atr_take_profit = entry_price + (atr * 3) if atr > 0 else entry_price * 1.15 + + # 2. 技术支撑止损法 + if sma_20 > 0 and sma_20 < entry_price: + support_stop_loss = sma_20 * 0.98 # SMA20下方2% + else: + support_stop_loss = entry_price * 0.95 + + # 3. 布林带止盈止损法 + if bb_lower > 0 and bb_upper > 0: + bollinger_stop_loss = bb_lower * 0.99 + bollinger_take_profit = bb_upper * 0.99 + else: + bollinger_stop_loss = entry_price * 0.93 + bollinger_take_profit = entry_price * 1.12 + + # 4. 波动率自适应方法 + volatility_multiplier = max(1.5, min(3.0, volatility / 20)) # 1.5-3倍波动率 + volatility_stop_loss = entry_price * (1 - 0.05 * volatility_multiplier) + volatility_take_profit = entry_price * (1 + 0.08 * volatility_multiplier) + + # 综合计算推荐的止盈止损位 + stop_loss_candidates = [atr_stop_loss, support_stop_loss, bollinger_stop_loss, volatility_stop_loss] + take_profit_candidates = [atr_take_profit, bollinger_take_profit, volatility_take_profit] + + # 选择相对保守的止损位(较高的那个) + recommended_stop_loss = max([sl for sl in stop_loss_candidates if sl > entry_price * 0.85]) + + # 选择合理的止盈位(避免过于激进) + recommended_take_profit = min([tp for tp in take_profit_candidates if tp < entry_price * 1.25]) + + # 确保符合A股涨跌停限制 + max_price = current_price * (1 + max_daily_change) + min_price = current_price * (1 - max_daily_change) + + return { + 'stop_loss': { + 'recommended': round(max(recommended_stop_loss, min_price), 2), + 'atr_based': round(max(atr_stop_loss, min_price), 2), + 'support_based': round(max(support_stop_loss, min_price), 2), + 'risk_ratio': round(abs(entry_price - recommended_stop_loss) / entry_price * 100, 2) + }, + 'take_profit': { + 'recommended': round(min(recommended_take_profit, max_price), 2), + 'conservative': round(min(entry_price * 1.08, max_price), 2), + 'aggressive': round(min(entry_price * 1.18, max_price), 2), + 'reward_ratio': round((recommended_take_profit - entry_price) / entry_price * 100, 2) + }, + 'risk_reward_ratio': round((recommended_take_profit - entry_price) / (entry_price - recommended_stop_loss), 2) if entry_price != recommended_stop_loss else 0, + 'position_sizing_suggestion': self._calculate_position_size(entry_price, recommended_stop_loss, volatility) + } + + def _analyze_trend_breakthrough(self, data: Dict) -> Dict: + """趋势突破分析""" + current_price = self.safe_value(data.get('current_price')) + sma_20 = self.safe_value(data.get('sma_20')) + sma_50 = self.safe_value(data.get('sma_50')) + volume_ratio = self.safe_value(data.get('volume_ratio', 1)) + + if current_price > sma_20 > sma_50 and volume_ratio > 1.2: + return { + 'signal': 'BUY', + 'weight': 3, + 'reason': '价格突破20日均线,趋势向上,成交量配合' + } + elif current_price > sma_20 and volume_ratio > 1.5: + return { + 'signal': 'BUY', + 'weight': 2, + 'reason': '价格站上20日均线,放量突破' + } + + return {'signal': 'NEUTRAL', 'weight': 3, 'reason': ''} + + def _analyze_momentum_confirmation(self, data: Dict) -> Dict: + """动量确认分析""" + rsi = self.safe_value(data.get('rsi', 50)) + macd = self.safe_value(data.get('macd', 0)) + macd_signal = self.safe_value(data.get('macd_signal', 0)) + stoch_k = self.safe_value(data.get('stoch_k', 50)) + + positive_signals = 0 + + if 30 <= rsi <= 70: # RSI健康区间 + positive_signals += 1 + if macd > macd_signal: # MACD金叉 + positive_signals += 1 + if 20 <= stoch_k <= 80: # KD指标适中 + positive_signals += 1 + + if positive_signals >= 2: + return { + 'signal': 'BUY', + 'weight': 2, + 'reason': f'多个动量指标确认买入信号({positive_signals}/3)' + } + + return {'signal': 'NEUTRAL', 'weight': 2, 'reason': ''} + + def _analyze_volume_confirmation(self, data: Dict) -> Dict: + """成交量确认分析""" + volume_ratio = self.safe_value(data.get('volume_ratio', 1)) + obv = self.safe_value(data.get('obv', 0)) + + if volume_ratio >= 1.8: + return { + 'signal': 'BUY', + 'weight': 2, + 'reason': f'显著放量({volume_ratio:.1f}倍),资金流入明显' + } + elif volume_ratio >= 1.3: + return { + 'signal': 'BUY', + 'weight': 1, + 'reason': f'温和放量({volume_ratio:.1f}倍),有资金关注' + } + elif volume_ratio < 0.7: + return { + 'signal': 'WARNING', + 'weight': 1, + 'reason': '成交量萎缩,市场关注度低' + } + + return {'signal': 'NEUTRAL', 'weight': 2, 'reason': ''} + + def _analyze_support_levels(self, data: Dict) -> Dict: + """支撑位分析""" + current_price = self.safe_value(data.get('current_price')) + bb_lower = self.safe_value(data.get('bb_lower')) + sma_20 = self.safe_value(data.get('sma_20')) + + if current_price > 0 and bb_lower > 0: + distance_to_support = (current_price - bb_lower) / current_price + + if 0.02 <= distance_to_support <= 0.05: # 接近支撑位但未跌破 + return { + 'signal': 'BUY', + 'weight': 2, + 'reason': '接近布林带下轨支撑位,反弹概率高' + } + + if current_price > sma_20 and sma_20 > 0: + return { + 'signal': 'BUY', + 'weight': 1, + 'reason': '价格站稳20日均线支撑' + } + + return {'signal': 'NEUTRAL', 'weight': 2, 'reason': ''} + + def _analyze_oversold_opportunity(self, data: Dict) -> Dict: + """超卖反弹机会分析""" + rsi = self.safe_value(data.get('rsi', 50)) + williams_r = self.safe_value(data.get('williams_r', -50)) + stoch_k = self.safe_value(data.get('stoch_k', 50)) + + oversold_count = 0 + if rsi < 30: + oversold_count += 1 + if williams_r < -80: + oversold_count += 1 + if stoch_k < 20: + oversold_count += 1 + + if oversold_count >= 2: + return { + 'signal': 'BUY', + 'weight': 2, + 'reason': f'多指标显示超卖({oversold_count}/3),反弹机会大' + } + + return {'signal': 'NEUTRAL', 'weight': 2, 'reason': ''} + + def _analyze_trend_breakdown(self, data: Dict) -> Dict: + """趋势破坏分析""" + current_price = self.safe_value(data.get('current_price')) + sma_20 = self.safe_value(data.get('sma_20')) + sma_50 = self.safe_value(data.get('sma_50')) + volume_ratio = self.safe_value(data.get('volume_ratio', 1)) + + if current_price < sma_20 < sma_50 and volume_ratio > 1.2: + return { + 'signal': 'SELL', + 'weight': 3, + 'reason': '跌破20日均线,趋势转弱,放量下跌' + } + elif current_price < sma_20: + return { + 'signal': 'SELL', + 'weight': 2, + 'reason': '跌破20日均线支撑' + } + + return {'signal': 'NEUTRAL', 'weight': 3, 'reason': ''} + + def _analyze_momentum_weakness(self, data: Dict) -> Dict: + """动量转弱分析""" + rsi = self.safe_value(data.get('rsi', 50)) + macd = self.safe_value(data.get('macd', 0)) + macd_signal = self.safe_value(data.get('macd_signal', 0)) + adx = self.safe_value(data.get('adx', 25)) + + weakness_signals = 0 + if rsi > 70: # RSI超买 + weakness_signals += 1 + if macd < macd_signal: # MACD死叉 + weakness_signals += 1 + if adx < 20: # 趋势力度减弱 + weakness_signals += 1 + + if weakness_signals >= 2: + return { + 'signal': 'SELL', + 'weight': 2, + 'reason': f'动量指标显示趋势转弱({weakness_signals}/3)' + } + + return {'signal': 'NEUTRAL', 'weight': 2, 'reason': ''} + + def _analyze_overbought_risk(self, data: Dict) -> Dict: + """超买风险分析""" + rsi = self.safe_value(data.get('rsi', 50)) + stoch_k = self.safe_value(data.get('stoch_k', 50)) + bb_position = self._calculate_bb_position(data) + + overbought_signals = 0 + if rsi > 80: + overbought_signals += 1 + if stoch_k > 80: + overbought_signals += 1 + if bb_position > 0.9: + overbought_signals += 1 + + if overbought_signals >= 2: + return { + 'signal': 'SELL', + 'weight': 2, + 'reason': f'多指标显示超买({overbought_signals}/3),回调风险高' + } + + return {'signal': 'NEUTRAL', 'weight': 2, 'reason': ''} + + def _analyze_volume_divergence(self, data: Dict) -> Dict: + """成交量背离分析""" + volume_ratio = self.safe_value(data.get('volume_ratio', 1)) + obv = self.safe_value(data.get('obv', 0)) + + if volume_ratio < 0.5: + return { + 'signal': 'SELL', + 'weight': 1, + 'reason': '成交量严重萎缩,市场参与度低' + } + + return {'signal': 'NEUTRAL', 'weight': 1, 'reason': ''} + + def _calculate_bb_position(self, data: Dict) -> float: + """计算布林带位置""" + current_price = self.safe_value(data.get('current_price')) + bb_upper = self.safe_value(data.get('bb_upper')) + bb_lower = self.safe_value(data.get('bb_lower')) + + if bb_upper > bb_lower and current_price > 0: + return (current_price - bb_lower) / (bb_upper - bb_lower) + return 0.5 + + def _calculate_position_size(self, entry_price: float, stop_loss: float, volatility: float) -> Dict: + """计算建议仓位""" + risk_per_trade = 0.02 # 单笔交易风险2% + max_position = 0.20 # 最大单股仓位20% + + if entry_price <= stop_loss: + return {'suggested_position': 0, 'reason': '止损位设置不当'} + + risk_amount = abs(entry_price - stop_loss) / entry_price + + # 基于风险调整仓位 + if risk_amount > 0: + position_size = min(risk_per_trade / risk_amount, max_position) + else: + position_size = 0.05 # 默认5% + + # 根据波动率调整 + if volatility > 40: # 高波动 + position_size *= 0.7 + elif volatility > 60: # 极高波动 + position_size *= 0.5 + + return { + 'suggested_position': round(position_size * 100, 1), # 转换为百分比 + 'risk_amount': round(risk_amount * 100, 2), + 'reason': f'基于{risk_amount*100:.1f}%风险和{volatility:.1f}%波动率计算' + } \ No newline at end of file diff --git a/src/data/__init__.py b/src/data/__init__.py new file mode 100644 index 0000000..f22beee --- /dev/null +++ b/src/data/__init__.py @@ -0,0 +1,5 @@ +from .tushare_client import TushareClient +from .database import Database +from .cache import RedisCache + +__all__ = ['TushareClient', 'Database', 'RedisCache'] \ No newline at end of file diff --git a/src/data/cache.py b/src/data/cache.py new file mode 100644 index 0000000..addd12c --- /dev/null +++ b/src/data/cache.py @@ -0,0 +1,64 @@ +import redis +import json +import logging +from typing import Any, Optional +from config.config import Config + +logger = logging.getLogger(__name__) + +class RedisCache: + def __init__(self): + try: + self.redis_client = redis.Redis( + host=Config.REDIS_HOST, + port=Config.REDIS_PORT, + db=Config.REDIS_DB, + decode_responses=True + ) + self.redis_client.ping() + except redis.ConnectionError: + logger.warning("Redis connection failed, using dummy cache") + self.redis_client = None + + def get(self, key: str) -> Optional[Any]: + if not self.redis_client: + return None + + try: + data = self.redis_client.get(key) + return json.loads(data) if data else None + except Exception as e: + logger.error(f"Redis get error: {e}") + return None + + def set(self, key: str, value: Any, expire: int = None) -> bool: + if not self.redis_client: + return False + + try: + expire = expire or Config.CACHE_EXPIRE + data = json.dumps(value, default=str) + return self.redis_client.setex(key, expire, data) + except Exception as e: + logger.error(f"Redis set error: {e}") + return False + + def delete(self, key: str) -> bool: + if not self.redis_client: + return False + + try: + return bool(self.redis_client.delete(key)) + except Exception as e: + logger.error(f"Redis delete error: {e}") + return False + + def exists(self, key: str) -> bool: + if not self.redis_client: + return False + + try: + return bool(self.redis_client.exists(key)) + except Exception as e: + logger.error(f"Redis exists error: {e}") + return False \ No newline at end of file diff --git a/src/data/database.py b/src/data/database.py new file mode 100644 index 0000000..308813a --- /dev/null +++ b/src/data/database.py @@ -0,0 +1,86 @@ +from sqlalchemy import create_engine, Column, String, Float, DateTime, Integer, Text +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +import pandas as pd +from config.config import Config +import logging + +logger = logging.getLogger(__name__) + +Base = declarative_base() + +class StockBasic(Base): + __tablename__ = 'stock_basic' + + ts_code = Column(String(20), primary_key=True) + symbol = Column(String(10)) + name = Column(String(50)) + area = Column(String(20)) + industry = Column(String(50)) + market = Column(String(10)) + list_date = Column(String(10)) + +class StockDaily(Base): + __tablename__ = 'stock_daily' + + id = Column(Integer, primary_key=True, autoincrement=True) + ts_code = Column(String(20)) + trade_date = Column(String(10)) + open = Column(Float) + high = Column(Float) + low = Column(Float) + close = Column(Float) + pre_close = Column(Float) + change = Column(Float) + pct_chg = Column(Float) + vol = Column(Float) + amount = Column(Float) + +class Database: + def __init__(self, database_url: str = None): + self.database_url = database_url or Config.DATABASE_URL + self.engine = create_engine(self.database_url) + Base.metadata.create_all(self.engine) + + Session = sessionmaker(bind=self.engine) + self.session = Session() + + def save_stock_basic(self, df: pd.DataFrame) -> bool: + try: + df.to_sql('stock_basic', self.engine, if_exists='replace', index=False) + return True + except Exception as e: + logger.error(f"Failed to save stock basic data: {e}") + return False + + def save_stock_daily(self, df: pd.DataFrame) -> bool: + try: + df.to_sql('stock_daily', self.engine, if_exists='append', index=False) + return True + except Exception as e: + logger.error(f"Failed to save stock daily data: {e}") + return False + + def get_stock_list(self) -> pd.DataFrame: + try: + return pd.read_sql_table('stock_basic', self.engine) + except Exception as e: + logger.error(f"Failed to get stock list: {e}") + return pd.DataFrame() + + def get_stock_daily(self, ts_code: str, start_date: str = None, end_date: str = None) -> pd.DataFrame: + try: + query = f"SELECT * FROM stock_daily WHERE ts_code = '{ts_code}'" + if start_date: + query += f" AND trade_date >= '{start_date}'" + if end_date: + query += f" AND trade_date <= '{end_date}'" + query += " ORDER BY trade_date DESC" + + return pd.read_sql_query(query, self.engine) + except Exception as e: + logger.error(f"Failed to get stock daily data: {e}") + return pd.DataFrame() + + def close(self): + self.session.close() \ No newline at end of file diff --git a/src/data/tushare_client.py b/src/data/tushare_client.py new file mode 100644 index 0000000..5005fcc --- /dev/null +++ b/src/data/tushare_client.py @@ -0,0 +1,142 @@ +import tushare as ts +import pandas as pd +import logging +from typing import Optional, Dict, List +from datetime import datetime, timedelta +from config.config import Config + +logger = logging.getLogger(__name__) + +class TushareClient: + def __init__(self, token: str = None): + self.token = token or Config.TUSHARE_TOKEN + if not self.token: + raise ValueError("Tushare token is required") + + ts.set_token(self.token) + self.pro = ts.pro_api() + + def get_stock_list(self, exchange: str = None) -> pd.DataFrame: + try: + return self.pro.stock_basic(exchange=exchange, list_status='L') + except Exception as e: + logger.error(f"Failed to get stock list: {e}") + return pd.DataFrame() + + def get_stock_daily(self, ts_code: str, start_date: str = None, + end_date: str = None) -> pd.DataFrame: + try: + if not start_date: + start_date = (datetime.now() - timedelta(days=365)).strftime('%Y%m%d') + if not end_date: + end_date = datetime.now().strftime('%Y%m%d') + + return self.pro.daily(ts_code=ts_code, start_date=start_date, end_date=end_date) + except Exception as e: + logger.error(f"Failed to get daily data for {ts_code}: {e}") + return pd.DataFrame() + + def get_financial_data(self, ts_code: str, period: str = None) -> Dict[str, pd.DataFrame]: + try: + data = {} + + income = self.pro.income(ts_code=ts_code, period=period) + data['income'] = income + + balance = self.pro.balancesheet(ts_code=ts_code, period=period) + data['balance'] = balance + + cashflow = self.pro.cashflow(ts_code=ts_code, period=period) + data['cashflow'] = cashflow + + return data + except Exception as e: + logger.error(f"Failed to get financial data for {ts_code}: {e}") + return {} + + def get_industry_classify(self, ts_code: str = None) -> pd.DataFrame: + try: + return self.pro.industry_classify(ts_code=ts_code) + except Exception as e: + logger.error(f"Failed to get industry classify: {e}") + return pd.DataFrame() + + def get_hs300_stocks(self) -> pd.DataFrame: + """获取沪深300成分股列表""" + try: + # 获取沪深300成分股 + hs300_stocks = self.pro.index_weight(index_code='399300.SZ') + if hs300_stocks.empty: + logger.warning("No HS300 stocks found, fallback to CSI300") + # 备选:使用中证300 + hs300_stocks = self.pro.index_weight(index_code='000300.SH') + + if hs300_stocks.empty: + logger.error("No HS300 component stocks found") + return pd.DataFrame() + + # 获取这些股票的基本信息 + ts_codes = hs300_stocks['con_code'].tolist() + stock_basic_list = [] + + # 批量获取股票基本信息 + for i in range(0, len(ts_codes), 50): # 每次获取50只股票 + batch_codes = ts_codes[i:i+50] + try: + batch_info = self.pro.stock_basic(ts_code=','.join(batch_codes)) + if not batch_info.empty: + stock_basic_list.append(batch_info) + except Exception as e: + logger.warning(f"Failed to get batch stock info: {e}") + continue + + if stock_basic_list: + result = pd.concat(stock_basic_list, ignore_index=True) + logger.info(f"Successfully retrieved {len(result)} HS300 component stocks") + return result + else: + logger.error("Failed to get any HS300 stock basic info") + return pd.DataFrame() + + except Exception as e: + logger.error(f"Failed to get HS300 stocks: {e}") + return pd.DataFrame() + + def get_stock_basic(self, ts_code: str) -> Optional[Dict]: + """获取股票基本信息""" + try: + # 获取股票基本信息 + basic_info = self.pro.stock_basic(ts_code=ts_code) + if basic_info.empty: + return None + + # 获取最新的日行情数据(当前价格和总市值) + latest_daily = self.pro.daily_basic(ts_code=ts_code, limit=1) + + # 组合基本信息 + result = { + 'ts_code': ts_code, + 'name': basic_info.iloc[0]['name'] if not basic_info.empty else '', + 'industry': basic_info.iloc[0]['industry'] if not basic_info.empty else '', + 'list_date': basic_info.iloc[0]['list_date'] if not basic_info.empty else '', + 'current_price': latest_daily.iloc[0]['close'] if not latest_daily.empty else 0, + 'market_cap': latest_daily.iloc[0]['total_mv'] if not latest_daily.empty and 'total_mv' in latest_daily.columns else 0, + } + + return result + + except Exception as e: + logger.error(f"Failed to get stock basic info for {ts_code}: {e}") + return None + + def get_trade_cal(self, start_date: str = None, end_date: str = None) -> pd.DataFrame: + try: + if not start_date: + start_date = datetime.now().strftime('%Y%m%d') + if not end_date: + end_date = (datetime.now() + timedelta(days=30)).strftime('%Y%m%d') + + return self.pro.trade_cal(start_date=start_date, end_date=end_date) + except Exception as e: + logger.error(f"Failed to get trade calendar: {e}") + return pd.DataFrame() \ No newline at end of file diff --git a/src/strategies/__init__.py b/src/strategies/__init__.py new file mode 100644 index 0000000..b02d346 --- /dev/null +++ b/src/strategies/__init__.py @@ -0,0 +1,6 @@ +from .base_strategy import BaseStrategy +from .value_strategy import ValueStrategy +from .growth_strategy import GrowthStrategy +from .technical_strategy import TechnicalStrategy + +__all__ = ['BaseStrategy', 'ValueStrategy', 'GrowthStrategy', 'TechnicalStrategy'] \ No newline at end of file diff --git a/src/strategies/base_strategy.py b/src/strategies/base_strategy.py new file mode 100644 index 0000000..69286e7 --- /dev/null +++ b/src/strategies/base_strategy.py @@ -0,0 +1,45 @@ +from abc import ABC, abstractmethod +import pandas as pd +from typing import Dict, List + +class BaseStrategy(ABC): + + def __init__(self, name: str, description: str): + self.name = name + self.description = description + self.weights = {} + + @abstractmethod + def calculate_score(self, stock_data: Dict) -> float: + pass + + @abstractmethod + def get_criteria(self) -> Dict: + pass + + def filter_stocks(self, stocks_data: List[Dict], min_score: float = 60) -> List[Dict]: + filtered_stocks = [] + + for stock in stocks_data: + try: + score = self.calculate_score(stock) + if score >= min_score: + stock['strategy_score'] = score + stock['strategy_name'] = self.name + filtered_stocks.append(stock) + except Exception as e: + print(f"Error calculating score for {stock.get('ts_code', 'unknown')}: {e}") + continue + + return sorted(filtered_stocks, key=lambda x: x['strategy_score'], reverse=True) + + def rank_stocks(self, stocks_data: List[Dict]) -> List[Dict]: + for stock in stocks_data: + try: + stock['strategy_score'] = self.calculate_score(stock) + stock['strategy_name'] = self.name + except Exception as e: + print(f"Error calculating score for {stock.get('ts_code', 'unknown')}: {e}") + stock['strategy_score'] = 0 + + return sorted(stocks_data, key=lambda x: x['strategy_score'], reverse=True) \ No newline at end of file diff --git a/src/strategies/growth_strategy.py b/src/strategies/growth_strategy.py new file mode 100644 index 0000000..eef5405 --- /dev/null +++ b/src/strategies/growth_strategy.py @@ -0,0 +1,176 @@ +from .base_strategy import BaseStrategy +from typing import Dict + +class GrowthStrategy(BaseStrategy): + + def __init__(self): + super().__init__( + name="成长投资策略", + description="基于高增长率、强盈利能力、良好发展前景的成长股选股策略" + ) + self.weights = { + 'revenue_growth_score': 0.25, + 'profit_growth_score': 0.20, + 'roe_score': 0.15, + 'margin_trend_score': 0.12, + 'innovation_score': 0.08, + 'asset_turnover_score': 0.08, # 新增资产效率 + 'working_capital_score': 0.07, # 新增营运资金管理 + 'dupont_roe_score': 0.05 # 新增杜邦分析 + } + + def calculate_score(self, stock_data: Dict) -> float: + try: + financial_ratios = stock_data.get('financial_ratios', {}) + + revenue_growth = financial_ratios.get('revenue_growth', 0) + profit_growth = financial_ratios.get('profit_growth', 0) + roe = financial_ratios.get('roe', 0) + margin_trend = financial_ratios.get('margin_trend', 0) + rd_expense_ratio = financial_ratios.get('rd_expense_ratio', 0) + asset_turnover = financial_ratios.get('asset_turnover', 0) # 新增 + working_capital_ratio = financial_ratios.get('working_capital_ratio', 0) # 新增 + dupont_roe = financial_ratios.get('dupont_roe', 0) # 新增 + + revenue_growth_score = self._score_revenue_growth(revenue_growth) + profit_growth_score = self._score_profit_growth(profit_growth) + roe_score = self._score_roe(roe) + margin_trend_score = self._score_margin_trend(margin_trend) + innovation_score = self._score_rd_expense(rd_expense_ratio) + asset_turnover_score = self._score_asset_turnover(asset_turnover) # 新增 + working_capital_score = self._score_working_capital(working_capital_ratio) # 新增 + dupont_roe_score = self._score_dupont_roe(dupont_roe) # 新增 + + total_score = ( + revenue_growth_score * self.weights['revenue_growth_score'] + + profit_growth_score * self.weights['profit_growth_score'] + + roe_score * self.weights['roe_score'] + + margin_trend_score * self.weights['margin_trend_score'] + + innovation_score * self.weights['innovation_score'] + + asset_turnover_score * self.weights['asset_turnover_score'] + + working_capital_score * self.weights['working_capital_score'] + + dupont_roe_score * self.weights['dupont_roe_score'] + ) + + return total_score + + except Exception as e: + print(f"Error in GrowthStrategy.calculate_score: {e}") + return 0 + + def _score_revenue_growth(self, growth_rate: float) -> float: + """营收增长率评分 - 适应A股实际情况""" + if growth_rate >= 25: # 超高增长 + return 100 + elif growth_rate >= 15: # 高增长 + return 80 + elif growth_rate >= 8: # 中等增长 + return 60 + elif growth_rate >= 3: # 低增长 + return 40 + elif growth_rate >= 0: # 正增长 + return 30 + else: # 负增长 + return 10 + + def _score_profit_growth(self, growth_rate: float) -> float: + """利润增长率评分 - 更现实的标准""" + if growth_rate >= 40: # 超高增长 + return 100 + elif growth_rate >= 20: # 高增长 + return 80 + elif growth_rate >= 10: # 中等增长 + return 60 + elif growth_rate >= 3: # 低增长 + return 40 + elif growth_rate >= 0: # 正增长 + return 30 + else: # 负增长 + return 10 + + def _score_roe(self, roe: float) -> float: + """ROE评分 - 成长股标准""" + if roe >= 20: # 优秀盈利能力 + return 100 + elif roe >= 15: # 良好盈利能力 + return 80 + elif roe >= 10: # 中等盈利能力 + return 60 + elif roe >= 5: # 较低盈利能力 + return 40 + else: # 盈利能力差 + return 20 + + def _score_margin_trend(self, margin_trend: float) -> float: + """利润率趋势评分 - 适应市场波动""" + if margin_trend >= 1.5: # 显著改善 + return 100 + elif margin_trend >= 0.5: # 明显改善 + return 80 + elif margin_trend >= 0: # 轻微改善 + return 60 + elif margin_trend >= -0.5: # 轻微恶化 + return 40 + else: # 明显恶化 + return 20 + + def _score_rd_expense(self, rd_ratio: float) -> float: + """研发投入评分 - 符合A股研发水平""" + if rd_ratio >= 8: # 高研发投入 + return 100 + elif rd_ratio >= 4: # 较高研发投入 + return 80 + elif rd_ratio >= 2: # 中等研发投入 + return 60 + elif rd_ratio >= 0.5: # 较低研发投入 + return 40 + else: # 无或极低研发投入 + return 20 + + def _score_asset_turnover(self, asset_turnover: float) -> float: + """资产周转率评分 - 成长股注重效率""" + if asset_turnover >= 1.5: # 高效率 + return 100 + elif asset_turnover >= 1.0: # 较高效率 + return 80 + elif asset_turnover >= 0.7: # 中等效率 + return 60 + elif asset_turnover >= 0.5: # 较低效率 + return 40 + else: # 低效率 + return 20 + + def _score_working_capital(self, wc_ratio: float) -> float: + """营运资金管理评分 - 成长股资金使用效率""" + if 5 <= wc_ratio <= 15: # 适度的营运资金比例 + return 100 + elif 0 <= wc_ratio <= 25: # 较合理的营运资金 + return 80 + elif -5 <= wc_ratio <= 30: # 可接受的范围 + return 60 + elif wc_ratio <= 40: # 营运资金过多,效率不高 + return 40 + else: # 营运资金管理不佳 + return 20 + + def _score_dupont_roe(self, dupont_roe: float) -> float: + """杜邦分析ROE评分 - 综合盈利能力分解""" + if dupont_roe >= 20: # 优秀的综合盈利能力 + return 100 + elif dupont_roe >= 15: # 良好的综合盈利能力 + return 80 + elif dupont_roe >= 10: # 中等的综合盈利能力 + return 60 + elif dupont_roe >= 5: # 较低的综合盈利能力 + return 40 + else: # 差的综合盈利能力 + return 20 + + def get_criteria(self) -> Dict: + return { + 'min_revenue_growth': 5, + 'min_profit_growth': 5, + 'min_roe': 10, + 'min_margin_trend': -1, + 'description': '从沪深300成分股中寻找高增长、高盈利、有创新能力的成长股' + } \ No newline at end of file diff --git a/src/strategies/technical_strategy.py b/src/strategies/technical_strategy.py new file mode 100644 index 0000000..8640c9a --- /dev/null +++ b/src/strategies/technical_strategy.py @@ -0,0 +1,519 @@ +from .base_strategy import BaseStrategy +from typing import Dict +from ..analysis.trading_signals import TradingSignalsAnalyzer + +class TechnicalStrategy(BaseStrategy): + + def __init__(self): + super().__init__( + name="技术分析策略", + description="基于技术指标、价格趋势、交易量的技术分析选股策略" + ) + self.weights = { + 'trend_score': 0.25, + 'momentum_score': 0.20, + 'volume_score': 0.15, + 'volatility_score': 0.12, + 'support_resistance_score': 0.10, + 'multi_timeframe_score': 0.08, # 新增多时间框架分析 + 'strength_score': 0.05, # 新增趋势强度分析 + 'divergence_score': 0.05 # 新增背离分析 + } + self.trading_analyzer = TradingSignalsAnalyzer() # 添加交易信号分析器 + + def calculate_score(self, stock_data: Dict) -> float: + try: + technical_indicators = stock_data.get('technical_indicators', {}) + + trend_score = self._score_trend(technical_indicators) + momentum_score = self._score_momentum(technical_indicators) + volume_score = self._score_volume(technical_indicators) + volatility_score = self._score_volatility(technical_indicators) + support_resistance_score = self._score_support_resistance(technical_indicators) + multi_timeframe_score = self._score_multi_timeframe(technical_indicators) # 新增 + strength_score = self._score_trend_strength(technical_indicators) # 新增 + divergence_score = self._score_price_volume_divergence(technical_indicators) # 新增 + + total_score = ( + trend_score * self.weights['trend_score'] + + momentum_score * self.weights['momentum_score'] + + volume_score * self.weights['volume_score'] + + volatility_score * self.weights['volatility_score'] + + support_resistance_score * self.weights['support_resistance_score'] + + multi_timeframe_score * self.weights['multi_timeframe_score'] + + strength_score * self.weights['strength_score'] + + divergence_score * self.weights['divergence_score'] + ) + + return total_score + + except Exception as e: + print(f"Error in TechnicalStrategy.calculate_score: {e}") + return 0 + + def _score_trend(self, indicators: Dict) -> float: + """趋势评分 - 增强容错性""" + try: + sma_20 = indicators.get('sma_20', 0) + sma_50 = indicators.get('sma_50', 0) + current_price = indicators.get('current_price', 0) + + if current_price == 0 or sma_20 == 0 or sma_50 == 0: + return 50 # 默认中等分数 + + # 强势上升趋势 + if current_price > sma_20 > sma_50: + return 90 + # 中等上升趋势 + elif current_price > sma_20: + return 70 + # 弱势上升趋势 + elif current_price > sma_50: + return 55 + # 横盘整理 + elif abs(current_price - sma_20) / current_price < 0.02: + return 50 + # 弱势下降趋势 + else: + return 30 + except: + return 50 + + def _score_momentum(self, indicators: Dict) -> float: + """动量评分 - 更宽松的RSI判断""" + try: + rsi = indicators.get('rsi', 50) + macd = indicators.get('macd', 0) + macd_signal = indicators.get('macd_signal', 0) + + # RSI评分 - 更适合A股波动 + if 40 <= rsi <= 65: # 健康区间 + rsi_score = 80 + elif 30 <= rsi <= 75: # 可接受区间 + rsi_score = 60 + elif rsi > 75: # 超买但仍可持有 + rsi_score = 40 + elif rsi < 30: # 超卖,潜在机会 + rsi_score = 70 + else: + rsi_score = 50 + + # MACD评分 + macd_score = 65 if macd > macd_signal else 35 + + return (rsi_score + macd_score) / 2 + except: + return 50 + + def _score_volume(self, indicators: Dict) -> float: + """成交量评分 - 适应A股特点""" + try: + current_volume = indicators.get('current_volume', 0) + volume_sma = indicators.get('volume_sma', 0) + + if volume_sma == 0 or current_volume == 0: + return 50 + + volume_ratio = current_volume / volume_sma + + # 成交量放大评分 + if volume_ratio >= 1.8: # 显著放量 + return 85 + elif volume_ratio >= 1.3: # 温和放量 + return 70 + elif volume_ratio >= 1.0: # 正常成交 + return 60 + elif volume_ratio >= 0.7: # 略微缩量 + return 45 + else: # 明显缩量 + return 30 + except: + return 50 + + def _score_volatility(self, indicators: Dict) -> float: + """波动率评分 - 适应A股市场""" + try: + volatility = indicators.get('volatility', 20) + + # A股适宜的波动率范围 + if 8 <= volatility <= 25: # 理想波动率 + return 80 + elif 5 <= volatility <= 35: # 可接受波动率 + return 65 + elif volatility <= 45: # 较高波动率 + return 50 + else: # 过高波动率 + return 30 + except: + return 50 + + def _score_support_resistance(self, indicators: Dict) -> float: + """支撑阻力评分 - 布林带位置""" + try: + current_price = indicators.get('current_price', 0) + bb_upper = indicators.get('bb_upper', 0) + bb_lower = indicators.get('bb_lower', 0) + bb_middle = indicators.get('bb_middle', 0) + + if bb_upper == 0 or bb_lower == 0 or current_price == 0: + return 50 + + bb_position = (current_price - bb_lower) / (bb_upper - bb_lower) + + # 布林带位置评分 + if 0.4 <= bb_position <= 0.6: # 中轨附近,相对安全 + return 75 + elif 0.25 <= bb_position <= 0.75: # 合理区间 + return 65 + elif 0.15 <= bb_position <= 0.85: # 可接受区间 + return 50 + else: # 极端位置 + return 35 + except: + return 50 + + def _score_multi_timeframe(self, indicators: Dict) -> float: + """多时间框架趋势一致性评分""" + try: + current_price = indicators.get('current_price', 0) + sma_20 = indicators.get('sma_20', 0) + sma_50 = indicators.get('sma_50', 0) + sma_200 = indicators.get('sma_200', 0) + + if current_price == 0: + return 50 + + # 短中长期趋势一致性分析 + short_trend = 1 if current_price > sma_20 else 0 + medium_trend = 1 if sma_20 > sma_50 else 0 + long_trend = 1 if sma_50 > sma_200 else 0 + + trend_alignment = short_trend + medium_trend + long_trend + + if trend_alignment == 3: # 三重趋势向上 + return 90 + elif trend_alignment == 2: # 两重趋势向上 + return 70 + elif trend_alignment == 1: # 一重趋势向上 + return 55 + else: # 趋势向下 + return 30 + except: + return 50 + + def _score_trend_strength(self, indicators: Dict) -> float: + """趋势强度评分 - ADX指标""" + try: + adx = indicators.get('adx', 25) + plus_di = indicators.get('plus_di', 20) + minus_di = indicators.get('minus_di', 20) + + # ADX趋势强度判断 + if adx >= 40: # 非常强的趋势 + strength_score = 90 + elif adx >= 25: # 强趋势 + strength_score = 70 + elif adx >= 15: # 中等趋势 + strength_score = 50 + else: # 弱趋势 + strength_score = 30 + + # DI方向判断 + if plus_di > minus_di: # 上升趋势 + return strength_score + else: # 下降趋势 + return max(strength_score - 20, 20) + except: + return 50 + + def _score_price_volume_divergence(self, indicators: Dict) -> float: + """价量背离分析评分""" + try: + current_volume = indicators.get('current_volume', 0) + volume_sma = indicators.get('volume_sma', 0) + obv = indicators.get('obv', 0) + pvt = indicators.get('pvt', 0) + + if current_volume == 0 or volume_sma == 0: + return 50 + + volume_ratio = current_volume / volume_sma + + # 价量配合度分析 + if volume_ratio >= 1.5: # 放量 + if obv > 0 and pvt > 0: # 价量同步上涨 + return 85 + elif obv > 0 or pvt > 0: # 部分同步 + return 65 + else: # 价量背离 + return 35 + elif volume_ratio >= 1.0: # 正常量能 + return 60 + else: # 缩量 + return 40 + except: + return 50 + + def get_trading_signals(self, stock_data: Dict) -> Dict: + """获取详细的交易信号分析""" + technical_indicators = stock_data.get('technical_indicators', {}) + current_price = technical_indicators.get('current_price', 0) + + # 计算技术分析综合评分 + overall_score = self.calculate_score(stock_data) + + # 分析买入信号 + entry_signals = self.trading_analyzer.analyze_entry_signals(technical_indicators) + + # 分析卖出信号 + exit_signals = self.trading_analyzer.analyze_exit_signals(technical_indicators) + + # 计算止盈止损 + stop_loss_take_profit = self.trading_analyzer.calculate_stop_loss_take_profit(technical_indicators) + + # 综合交易建议(考虑综合评分) + trading_advice = self._generate_trading_advice(entry_signals, exit_signals, stop_loss_take_profit, overall_score) + + # 计算建议入场和出场价格 + entry_price = self._calculate_entry_price(technical_indicators, entry_signals) + exit_price = self._calculate_exit_price(technical_indicators, exit_signals) + + return { + 'current_price': current_price, + 'overall_score': overall_score, + 'entry_signals': entry_signals, + 'exit_signals': exit_signals, + 'stop_loss_take_profit': stop_loss_take_profit, + 'trading_advice': trading_advice, + 'entry_price': entry_price, + 'exit_price': exit_price, + 'market_timing': self._assess_market_timing(technical_indicators) + } + + def _generate_trading_advice(self, entry_signals: Dict, exit_signals: Dict, stp: Dict, overall_score: float = 50) -> Dict: + """生成综合交易建议""" + advice = { + 'action': 'WAIT', + 'priority': 'LOW', + 'reasoning': [], + 'risk_assessment': 'MEDIUM', + 'time_horizon': 'SHORT_TERM' # SHORT_TERM/MEDIUM_TERM/LONG_TERM + } + + # 考虑综合评分对交易建议的影响 + score_bias = 0 + if overall_score >= 75: + score_bias = 2 # 高分偏向买入 + advice['reasoning'].append(f'技术分析综合评分{overall_score:.1f}分,表现优异') + elif overall_score >= 60: + score_bias = 1 # 中等分数轻微偏向买入 + advice['reasoning'].append(f'技术分析综合评分{overall_score:.1f}分,表现良好') + elif overall_score <= 40: + score_bias = -2 # 低分偏向卖出 + advice['reasoning'].append(f'技术分析综合评分{overall_score:.1f}分,表现较差') + elif overall_score <= 50: + score_bias = -1 # 稍低分数轻微偏向观望 + advice['reasoning'].append(f'技术分析综合评分{overall_score:.1f}分,表现一般') + + # 基于买入信号强度(考虑评分调整) + entry_strength = 0 + if entry_signals['overall_signal'] == 'STRONG_BUY': + entry_strength = 3 + elif entry_signals['overall_signal'] == 'BUY': + entry_strength = 2 + elif entry_signals['overall_signal'] == 'WEAK_BUY': + entry_strength = 1 + elif entry_signals['overall_signal'] == 'NEUTRAL': + entry_strength = 0 + + # 基于卖出信号强度 + exit_strength = 0 + if exit_signals['overall_signal'] == 'STRONG_SELL': + exit_strength = -3 + elif exit_signals['overall_signal'] == 'SELL': + exit_strength = -2 + elif exit_signals['overall_signal'] == 'WEAK_SELL': + exit_strength = -1 + + # 综合判断(信号强度 + 评分偏向) + final_strength = entry_strength + exit_strength + score_bias + + # 根据最终强度确定交易建议 + if final_strength >= 3: + advice['action'] = 'BUY' + advice['priority'] = 'HIGH' + if entry_signals.get('entry_reasons'): + advice['reasoning'].extend(entry_signals['entry_reasons']) + elif final_strength >= 2: + advice['action'] = 'BUY' + advice['priority'] = 'MEDIUM' + if entry_signals.get('entry_reasons'): + advice['reasoning'].extend(entry_signals['entry_reasons']) + elif final_strength >= 1: + advice['action'] = 'CONSIDER_BUY' + advice['priority'] = 'LOW' + advice['reasoning'].append('综合信号偏向买入,但建议谨慎考虑') + elif final_strength <= -3: + advice['action'] = 'SELL' + advice['priority'] = 'HIGH' + if exit_signals.get('exit_reasons'): + advice['reasoning'].extend(exit_signals['exit_reasons']) + elif final_strength <= -2: + advice['action'] = 'SELL' + advice['priority'] = 'MEDIUM' + if exit_signals.get('exit_reasons'): + advice['reasoning'].extend(exit_signals['exit_reasons']) + else: + advice['action'] = 'WAIT' + advice['priority'] = 'LOW' + advice['reasoning'].append('当前信号不明确,建议继续观望') + + # 调整风险评估 + risk_factors = len(exit_signals.get('risk_flags', [])) + len(entry_signals.get('warning_flags', [])) + if overall_score >= 70 and risk_factors == 0: + advice['risk_assessment'] = 'LOW' + elif overall_score >= 50 and risk_factors <= 1: + advice['risk_assessment'] = 'MEDIUM' + else: + advice['risk_assessment'] = 'HIGH' + + # 时间框架建议 + if stp.get('risk_reward_ratio', 0) >= 2: + advice['time_horizon'] = 'MEDIUM_TERM' + elif stp.get('risk_reward_ratio', 0) >= 1.5: + advice['time_horizon'] = 'SHORT_TERM' + else: + advice['time_horizon'] = 'INTRADAY' + + return advice + + def _assess_market_timing(self, indicators: Dict) -> Dict: + """市场时机评估""" + volatility = indicators.get('volatility', 20) + volume_ratio = indicators.get('volume_ratio', 1) + adx = indicators.get('adx', 25) + + timing = { + 'market_condition': 'NORMAL', + 'liquidity': 'GOOD', + 'trend_strength': 'MEDIUM', + 'optimal_for_trading': True, + 'recommendations': [] + } + + # 市场状态评估 + if volatility > 50: + timing['market_condition'] = 'VOLATILE' + timing['recommendations'].append('高波动环境,建议降低仓位') + elif volatility < 10: + timing['market_condition'] = 'QUIET' + timing['recommendations'].append('市场平静,适合区间操作') + + # 流动性评估 + if volume_ratio < 0.5: + timing['liquidity'] = 'POOR' + timing['optimal_for_trading'] = False + timing['recommendations'].append('成交量不足,不宜大额交易') + elif volume_ratio > 2: + timing['liquidity'] = 'EXCELLENT' + timing['recommendations'].append('成交活跃,适合短线操作') + + # 趋势强度评估 + if adx > 40: + timing['trend_strength'] = 'STRONG' + timing['recommendations'].append('强趋势环境,适合趋势跟随') + elif adx < 20: + timing['trend_strength'] = 'WEAK' + timing['recommendations'].append('趋势不明,建议区间操作') + + return timing + + def _calculate_entry_price(self, technical_indicators: Dict, entry_signals: Dict) -> Dict: + """计算建议入场价格""" + current_price = technical_indicators.get('current_price', 0) + if current_price <= 0: + return {'recommended': None, 'reasoning': '无当前价格数据'} + + sma_20 = technical_indicators.get('sma_20', 0) + bb_lower = technical_indicators.get('bb_lower', 0) + support_level = technical_indicators.get('support_level', 0) + + # 根据信号强度确定入场价格策略 + if entry_signals.get('overall_signal') == 'STRONG_BUY': + # 强买入信号:当前价格入场 + return { + 'recommended': round(current_price, 2), + 'reasoning': '强买入信号,建议当前价格入场' + } + elif entry_signals.get('overall_signal') in ['BUY', 'WEAK_BUY']: + # 买入信号:寻找更好入场点 + entry_candidates = [current_price] + + # 如果价格接近支撑位,等待触及支撑 + if bb_lower > 0 and current_price > bb_lower: + distance_to_support = (current_price - bb_lower) / current_price + if distance_to_support < 0.03: # 距离支撑位3%以内 + entry_candidates.append(bb_lower * 1.005) # 支撑位上方0.5% + + # 如果在20日均线上方不远,等待回调 + if sma_20 > 0 and current_price > sma_20: + distance_to_sma = (current_price - sma_20) / current_price + if distance_to_sma < 0.05: # 距离均线5%以内 + entry_candidates.append(sma_20 * 1.01) # 均线上方1% + + recommended_price = min(entry_candidates) + return { + 'recommended': round(recommended_price, 2), + 'reasoning': f'建议在{recommended_price:.2f}附近入场,等待更好买点' + } + + return {'recommended': None, 'reasoning': '暂无买入信号'} + + def _calculate_exit_price(self, technical_indicators: Dict, exit_signals: Dict) -> Dict: + """计算建议出场价格""" + current_price = technical_indicators.get('current_price', 0) + if current_price <= 0: + return {'recommended': None, 'reasoning': '无当前价格数据'} + + bb_upper = technical_indicators.get('bb_upper', 0) + resistance_level = technical_indicators.get('resistance_level', 0) + sma_20 = technical_indicators.get('sma_20', 0) + + # 根据信号强度确定出场价格策略 + if exit_signals.get('overall_signal') == 'STRONG_SELL': + # 强卖出信号:立即出场 + return { + 'recommended': round(current_price, 2), + 'reasoning': '强卖出信号,建议立即出场' + } + elif exit_signals.get('overall_signal') in ['SELL', 'WEAK_SELL']: + # 卖出信号:寻找更好出场点 + exit_candidates = [current_price] + + # 如果接近阻力位,等待触及阻力位 + if bb_upper > 0 and current_price < bb_upper: + distance_to_resistance = (bb_upper - current_price) / current_price + if distance_to_resistance < 0.03: # 距离阻力位3%以内 + exit_candidates.append(bb_upper * 0.995) # 阻力位下方0.5% + + # 如果在关键阻力位下方,等待反弹 + if resistance_level > current_price > 0: + distance_to_resistance = (resistance_level - current_price) / current_price + if distance_to_resistance < 0.05: # 距离阻力位5%以内 + exit_candidates.append(resistance_level * 0.99) # 阻力位下方1% + + recommended_price = max(exit_candidates) + return { + 'recommended': round(recommended_price, 2), + 'reasoning': f'建议在{recommended_price:.2f}附近出场,等待更好卖点' + } + + return {'recommended': None, 'reasoning': '暂无卖出信号'} + + def get_criteria(self) -> Dict: + return { + 'min_volume_ratio': 0.8, + 'max_volatility': 60, + 'rsi_range': [30, 70], + 'description': '从沪深300成分股中寻找技术面良好、趋势向上、成交活跃的股票' + } \ No newline at end of file diff --git a/src/strategies/value_strategy.py b/src/strategies/value_strategy.py new file mode 100644 index 0000000..1f6dfd6 --- /dev/null +++ b/src/strategies/value_strategy.py @@ -0,0 +1,179 @@ +from .base_strategy import BaseStrategy +from typing import Dict + +class ValueStrategy(BaseStrategy): + + def __init__(self): + super().__init__( + name="价值投资策略", + description="基于低估值、高股息、稳定盈利的价值投资选股策略" + ) + self.weights = { + 'pe_score': 0.20, + 'pb_score': 0.15, + 'roe_score': 0.20, + 'debt_score': 0.15, + 'dividend_score': 0.08, + 'margin_score': 0.10, + 'cash_flow_score': 0.07, # 新增现金流质量 + 'asset_turnover_score': 0.05 # 新增资产效率 + } + + def calculate_score(self, stock_data: Dict) -> float: + try: + financial_ratios = stock_data.get('financial_ratios', {}) + + pe_ratio = financial_ratios.get('pe_ratio', float('inf')) + pb_ratio = financial_ratios.get('pb_ratio', float('inf')) + roe = financial_ratios.get('roe', 0) + debt_ratio = financial_ratios.get('debt_ratio', 100) + net_margin = financial_ratios.get('net_margin', 0) + dividend_yield = financial_ratios.get('dividend_yield', 0) + ocf_ratio = financial_ratios.get('ocf_ratio', 0) # 新增现金流比率 + asset_turnover = financial_ratios.get('asset_turnover', 0) # 新增资产周转率 + + pe_score = self._score_pe_ratio(pe_ratio) + pb_score = self._score_pb_ratio(pb_ratio) + roe_score = self._score_roe(roe) + debt_score = self._score_debt_ratio(debt_ratio) + margin_score = self._score_net_margin(net_margin) + dividend_score = self._score_dividend_yield(dividend_yield) + cash_flow_score = self._score_cash_flow_quality(ocf_ratio) # 新增现金流评分 + asset_turnover_score = self._score_asset_turnover(asset_turnover) # 新增效率评分 + + total_score = ( + pe_score * self.weights['pe_score'] + + pb_score * self.weights['pb_score'] + + roe_score * self.weights['roe_score'] + + debt_score * self.weights['debt_score'] + + margin_score * self.weights['margin_score'] + + dividend_score * self.weights['dividend_score'] + + cash_flow_score * self.weights['cash_flow_score'] + + asset_turnover_score * self.weights['asset_turnover_score'] + ) + + return total_score + + except Exception as e: + print(f"Error in ValueStrategy.calculate_score: {e}") + return 0 + + def _score_pe_ratio(self, pe_ratio: float) -> float: + """PE比率评分 - 适应A股市场""" + if pe_ratio <= 0 or pe_ratio == float('inf'): + return 0 + elif pe_ratio <= 15: # 优秀估值 + return 100 + elif pe_ratio <= 25: # 合理估值 + return 80 + elif pe_ratio <= 45: # 偏高但可接受 + return 60 + elif pe_ratio <= 60: # 较高估值 + return 40 + else: # 过高估值 + return 20 + + def _score_pb_ratio(self, pb_ratio: float) -> float: + """PB比率评分 - 更现实的标准""" + if pb_ratio <= 0 or pb_ratio == float('inf'): + return 0 + elif pb_ratio <= 1.5: # 低估值 + return 100 + elif pb_ratio <= 3.0: # 合理估值 + return 80 + elif pb_ratio <= 5.0: # 中等估值 + return 60 + elif pb_ratio <= 8.0: # 偏高估值 + return 40 + else: # 高估值 + return 20 + + def _score_roe(self, roe: float) -> float: + """ROE评分 - 符合A股实际情况""" + if roe >= 18: # 优秀盈利能力 + return 100 + elif roe >= 12: # 良好盈利能力 + return 80 + elif roe >= 8: # 中等盈利能力 + return 60 + elif roe >= 3: # 较低盈利能力 + return 40 + else: # 盈利能力差 + return 20 + + def _score_debt_ratio(self, debt_ratio: float) -> float: + """负债比率评分 - 调整为更宽松的标准""" + if debt_ratio <= 35: # 低负债 + return 100 + elif debt_ratio <= 55: # 中等负债 + return 80 + elif debt_ratio <= 75: # 偏高负债 + return 60 + elif debt_ratio <= 85: # 高负债 + return 40 + else: # 过高负债 + return 20 + + def _score_net_margin(self, net_margin: float) -> float: + """净利率评分 - 适应不同行业""" + if net_margin >= 12: # 高利润率 + return 100 + elif net_margin >= 8: # 较高利润率 + return 80 + elif net_margin >= 4: # 中等利润率 + return 60 + elif net_margin >= 1: # 较低利润率 + return 40 + else: # 低利润率 + return 20 + + def _score_dividend_yield(self, dividend_yield: float) -> float: + """股息率评分 - 符合A股分红水平""" + if dividend_yield >= 4: # 高股息 + return 100 + elif dividend_yield >= 2.5: # 较高股息 + return 80 + elif dividend_yield >= 1.5: # 中等股息 + return 60 + elif dividend_yield >= 0.5: # 较低股息 + return 40 + else: # 无或极低股息 + return 20 + + def _score_cash_flow_quality(self, ocf_ratio: float) -> float: + """现金流质量评分 - 经营现金流与净利润比率""" + if ocf_ratio >= 120: # 现金流远超净利润 + return 100 + elif ocf_ratio >= 100: # 现金流匹配净利润 + return 90 + elif ocf_ratio >= 80: # 现金流略低于净利润 + return 70 + elif ocf_ratio >= 60: # 现金流明显低于净利润 + return 50 + elif ocf_ratio >= 40: # 现金流质量一般 + return 30 + else: # 现金流质量差 + return 10 + + def _score_asset_turnover(self, asset_turnover: float) -> float: + """资产周转率评分 - 资产使用效率""" + if asset_turnover >= 1.5: # 高效率 + return 100 + elif asset_turnover >= 1.0: # 较高效率 + return 80 + elif asset_turnover >= 0.7: # 中等效率 + return 60 + elif asset_turnover >= 0.5: # 较低效率 + return 40 + else: # 低效率 + return 20 + + def get_criteria(self) -> Dict: + return { + 'min_pe': 35, + 'max_pb': 3.0, + 'min_roe': 5, + 'max_debt_ratio': 80, + 'min_net_margin': 2, + 'description': '从沪深300成分股中寻找被低估、财务稳健、盈利能力强的价值股' + } \ No newline at end of file diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e8fe4e9 --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1,3 @@ +from .helpers import setup_logger, safe_divide, format_number, get_current_date, validate_ts_code + +__all__ = ['setup_logger', 'safe_divide', 'format_number', 'get_current_date', 'validate_ts_code'] \ No newline at end of file diff --git a/src/utils/helpers.py b/src/utils/helpers.py new file mode 100644 index 0000000..29c2df3 --- /dev/null +++ b/src/utils/helpers.py @@ -0,0 +1,54 @@ +import logging +from datetime import datetime + +def setup_logger(name: str, log_file: str = None, level: str = 'INFO') -> logging.Logger: + logger = logging.getLogger(name) + logger.setLevel(getattr(logging, level.upper())) + + if not logger.handlers: + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + if log_file: + file_handler = logging.FileHandler(log_file) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger + +def safe_divide(a: float, b: float, default: float = 0) -> float: + try: + return a / b if b != 0 else default + except (TypeError, ZeroDivisionError): + return default + +def format_number(num: float, decimals: int = 2) -> str: + try: + if num == float('inf'): + return '∞' + elif num == float('-inf'): + return '-∞' + else: + return f"{num:.{decimals}f}" + except: + return "N/A" + +def get_current_date() -> str: + return datetime.now().strftime('%Y%m%d') + +def validate_ts_code(ts_code: str) -> bool: + if not ts_code or not isinstance(ts_code, str): + return False + + parts = ts_code.split('.') + if len(parts) != 2: + return False + + stock_code, exchange = parts + return (len(stock_code) == 6 and stock_code.isdigit() and + exchange in ['SH', 'SZ']) \ No newline at end of file diff --git a/start_web.sh b/start_web.sh new file mode 100755 index 0000000..aa53355 --- /dev/null +++ b/start_web.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +echo "启动现代化股票分析平台..." + +# 检查虚拟环境 +if [ ! -d "venv" ]; then + echo "创建虚拟环境..." + python3 -m venv venv +fi + +# 激活虚拟环境 +echo "激活虚拟环境..." +source venv/bin/activate + +# 安装依赖 +echo "安装依赖..." +pip install -r web/requirements.txt + +# 启动应用 +echo "启动Web应用..." +echo "访问地址: http://localhost:5001" +echo "按 Ctrl+C 停止服务" +cd web && python app.py \ No newline at end of file diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..c6be04c --- /dev/null +++ b/web/README.md @@ -0,0 +1,108 @@ +# 现代化股票分析平台 + +一个基于Flask + HTML/CSS/JavaScript的现代化股票分析平台,提供美观的用户界面和丰富的交互体验。 + +## ✨ 特性 + +- **🎨 现代化设计**: 玻璃拟态效果、渐变背景、流畅动画 +- **📱 响应式布局**: 完美适配桌面和移动设备 +- **⚡ 快速加载**: 轻量级架构,无需重型框架 +- **🎯 交互丰富**: 实时图表、动态加载、键盘快捷键 +- **🌙 深色模式**: 自动适配系统主题偏好 + +## 🚀 快速启动 + +### 方法一:使用启动脚本 +```bash +./start_web.sh +``` + +### 方法二:手动启动 +```bash +# 1. 激活虚拟环境 +source venv/bin/activate + +# 2. 安装依赖 +pip install -r web/requirements.txt + +# 3. 启动应用 +cd web && python app.py +``` + +## 🌐 访问地址 + +启动后访问: [http://localhost:5001](http://localhost:5001) + +## 📋 功能模块 + +### 1. 股票筛选 +- 多种投资策略选择(价值投资、成长投资、技术分析) +- 可调节筛选条件(最低评分、结果数量) +- 实时图表展示(评分排行、行业分布) + +### 2. 单股分析 +- 股票代码输入验证 +- 综合评分和投资建议 +- 详细财务指标展示 +- 雷达图可视化 + +### 3. 策略对比 +- 多策略并行分析 +- 对比结果可视化 +- 最佳策略推荐 + +## ⌨️ 键盘快捷键 + +- `Ctrl + 1`: 切换到股票筛选 +- `Ctrl + 2`: 切换到单股分析 +- `Ctrl + 3`: 切换到策略对比 +- `Ctrl + Enter`: 执行当前页面主要操作 + +## 🎨 设计特色 + +- **玻璃拟态**: 半透明背景 + 毛玻璃模糊效果 +- **渐变主题**: 蓝紫色渐变配色方案 +- **流畅动画**: 页面切换、加载状态、悬停效果 +- **现代排版**: Inter 字体 + 合理的层次结构 +- **智能提示**: Toast 通知 + 加载状态反馈 + +## 🔧 技术栈 + +- **前端**: HTML5 + CSS3 + Vanilla JavaScript +- **后端**: Flask (轻量级Python Web框架) +- **图表**: Chart.js +- **图标**: Font Awesome +- **字体**: Google Fonts (Inter) + +## 📱 响应式适配 + +- **桌面**: 1200px+ (多列布局) +- **平板**: 768px-1199px (双列布局) +- **手机**: <768px (单列布局) + +## 🎯 与Streamlit对比 + +| 特性 | Streamlit | 现代化平台 | +|------|-----------|------------| +| 设计自由度 | 受限 | 完全自定义 | +| 加载速度 | 较慢 | 快速 | +| 交互体验 | 基础 | 丰富 | +| 移动适配 | 一般 | 优秀 | +| 定制程度 | 低 | 高 | + +## 🔄 项目结构 + +``` +web/ +├── app.py # Flask后端 +├── requirements.txt # Python依赖 +├── templates/ +│ └── index.html # 主页面 +└── static/ + ├── css/ + │ └── style.css # 样式文件 + └── js/ + └── app.js # 交互脚本 +``` + +现在你拥有了一个真正现代化的股票分析平台!🎉 \ No newline at end of file diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..f197e39 --- /dev/null +++ b/web/app.py @@ -0,0 +1,35 @@ +from flask import Flask, render_template, jsonify, request +import requests +import json + +app = Flask(__name__) + +# API配置 +API_BASE_URL = "http://localhost:8000/api" + +@app.route('/') +def index(): + return render_template('index.html') + +@app.route('/api/screen', methods=['POST']) +def screen_stocks(): + """股票筛选代理接口""" + try: + data = request.json + response = requests.post(f"{API_BASE_URL}/screen", json=data) + return jsonify(response.json()), response.status_code + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/analyze', methods=['POST']) +def analyze_stock(): + """单股分析代理接口""" + try: + data = request.json + response = requests.post(f"{API_BASE_URL}/analyze", json=data) + return jsonify(response.json()), response.status_code + except Exception as e: + return jsonify({'error': str(e)}), 500 + +if __name__ == '__main__': + app.run(debug=True, port=5001) \ No newline at end of file diff --git a/web/requirements.txt b/web/requirements.txt new file mode 100644 index 0000000..ebc1cd5 --- /dev/null +++ b/web/requirements.txt @@ -0,0 +1,2 @@ +Flask==2.3.3 +requests==2.31.0 \ No newline at end of file diff --git a/web/static/css/style.css b/web/static/css/style.css new file mode 100644 index 0000000..d601158 --- /dev/null +++ b/web/static/css/style.css @@ -0,0 +1,743 @@ +/* 全局重置和基础样式 */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + line-height: 1.6; + color: #000000; /* 纯黑色文字确保最高对比度 */ + background: #f8f9fa; /* 浅灰背景增加层次感 */ + min-height: 100vh; +} + +/* 导航栏 */ +.navbar { + background: #ffffff; + border-bottom: 1px solid #e5e5e5; + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + padding: 0 2rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.nav-container { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1200px; + margin: 0 auto; + height: 60px; +} + +.nav-brand { + display: flex; + align-items: center; + gap: 10px; + font-size: 1.4rem; + font-weight: 700; + color: #000000; /* 纯黑色确保清晰 */ +} + +.nav-menu { + display: flex; + gap: 1rem; +} + +.nav-link { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + text-decoration: none; + color: #333333; /* 深灰色确保在白底上清晰可见 */ + border-radius: 6px; + transition: all 0.2s ease; + font-weight: 500; + font-size: 0.9rem; +} + +.nav-link:hover, .nav-link.active { + background: #f0f0f0; + color: #000000; /* 悬停时变为纯黑 */ +} + +/* 主容器 */ +.main-container { + margin-top: 80px; + padding: 2rem; + max-width: 1200px; + margin-left: auto; + margin-right: auto; +} + +/* 页面标题 */ +.page-header { + text-align: center; + margin-bottom: 3rem; + color: #000000; /* 标题用纯黑色 */ +} + +.page-header h1 { + font-size: 2.2rem; + font-weight: 600; + margin-bottom: 0.5rem; + letter-spacing: -0.02em; + color: #000000; /* 确保标题清晰 */ +} + +.page-header p { + font-size: 1rem; + color: #444444; /* 副标题用深灰色 */ + max-width: 600px; + margin: 0 auto; +} + +/* 卡片样式 */ +.card { + background: #fafafa; /* 更浅的背景色 */ + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); /* 更轻的阴影 */ + border: 1px solid #f0f0f0; /* 更浅的边框 */ + transition: box-shadow 0.2s ease; +} + +.card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); /* 悬停时稍微深一点 */ +} + +.card-header { + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid #f0f0f0; +} + +.card-header h3 { + font-size: 1.2rem; + font-weight: 600; + color: #000000; /* 卡片标题用纯黑色 */ +} + +/* 表单样式 */ +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 2rem; + margin-bottom: 2rem; +} + +.form-group { + display: flex; + flex-direction: column; +} + +.form-group label { + font-weight: 600; + color: #000000; /* 表单标签用纯黑色 */ + margin-bottom: 6px; + font-size: 0.9rem; +} + +.form-input, .form-select { + padding: 10px 12px; + border: 1px solid #ccc; /* 更深的边框确保可见 */ + border-radius: 6px; + font-size: 0.9rem; + transition: border-color 0.2s ease; + background: #ffffff; + color: #000000; /* 输入文字用纯黑色 */ +} + +.form-input:focus, .form-select:focus { + outline: none; + border-color: #000000; /* 聚焦时黑色边框 */ +} + +.form-range { + width: 100%; + height: 8px; + border-radius: 5px; + background: #e2e8f0; + outline: none; + -webkit-appearance: none; + margin-bottom: 8px; +} + +.form-range::-webkit-slider-thumb { + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #000000; /* 纯黑色 */ + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); /* 简单阴影 */ +} + +.form-range::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: #000000; /* 纯黑色 */ + cursor: pointer; + border: none; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); /* 简单阴影 */ +} + +.range-value { + font-weight: 600; + color: #000000; /* 纯黑色 */ + text-align: center; +} + +/* 按钮样式 */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border: none; + border-radius: 6px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + justify-content: center; +} + +.btn-primary { + background: #1a1a1a; + color: #ffffff; +} + +.btn-primary:hover { + background: #333333; +} + +.btn-primary:active { + background: #0d0d0d; +} + +/* 指标卡片 */ +.metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.metric-card { + background: #fafafa; /* 与主卡片保持一致的浅背景 */ + border-radius: 8px; + padding: 1.5rem; + text-align: center; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); /* 更轻的阴影 */ + border: 1px solid #f0f0f0; /* 更浅的边框 */ + transition: box-shadow 0.2s ease; +} + +.metric-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); /* 悬停时稍微深一点 */ +} + +.metric-icon { + font-size: 1.8rem; + margin-bottom: 0.8rem; +} + +.metric-value { + font-size: 1.6rem; + font-weight: 700; + color: #000000; /* 指标数值用纯黑色 */ + margin-bottom: 0.3rem; +} + +.metric-label { + color: #333333; /* 指标标签用深灰色 */ + font-weight: 500; + font-size: 0.9rem; +} + +/* 图表容器 */ +.charts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + gap: 2rem; + margin-top: 2rem; +} + +.chart-container { + background: #fafafa; /* 与其他卡片保持一致 */ + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); /* 更轻的阴影 */ + border: 1px solid #f0f0f0; /* 更浅的边框 */ +} + +/* 对比网格 */ +.comparison-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +/* 表格样式 */ +.data-table { + width: 100%; + border-collapse: collapse; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + margin-bottom: 2rem; + border: 1px solid #e5e5e5; +} + +.data-table th { + background: #000000; /* 表头用纯黑背景 */ + color: #ffffff; + padding: 12px 16px; + text-align: left; + font-weight: 600; + font-size: 0.9rem; +} + +.data-table td { + padding: 12px 16px; + border-bottom: 1px solid #f0f0f0; /* 更浅的分隔线 */ + background: #fafafa; /* 与卡片背景一致 */ + color: #000000; /* 表格内容用纯黑色 */ + font-size: 0.9rem; +} + +.data-table tr:hover td { + background: #f0f0f0; /* 悬停时稍微深一点 */ + color: #000000; +} + +/* 选项卡内容 */ +.tab-content { + display: none; + animation: fadeIn 0.3s ease; +} + +.tab-content.active { + display: block; +} + +.results-section { + animation: slideUp 0.5s ease; +} + +/* 加载动画 */ +.loading-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.95); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 2000; + color: #000000; /* 加载文字用纯黑色 */ +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid #e0e0e0; + border-top: 3px solid #000000; /* 旋转部分用黑色 */ + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 1rem; +} + +/* Toast 通知 */ +#toast-container { + position: fixed; + top: 100px; + right: 2rem; + z-index: 3000; +} + +.toast { + background: #fafafa; /* 与卡片背景一致 */ + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); /* 稍微明显的阴影 */ + border: 1px solid #f0f0f0; /* 与其他元素一致的边框 */ + border-left: 3px solid #000000; /* 左边框用黑色 */ + animation: slideInRight 0.3s ease; + max-width: 300px; + font-size: 0.9rem; + color: #000000; /* Toast文字用纯黑色 */ +} + +.toast.success { + border-left-color: #22c55e; +} + +.toast.error { + border-left-color: #ef4444; +} + +.toast.warning { + border-left-color: #f59e0b; +} + +/* 动画 */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .navbar { + padding: 0 1rem; + } + + .nav-container { + height: 60px; + } + + .nav-brand { + font-size: 1.2rem; + } + + .nav-menu { + gap: 1rem; + } + + .nav-link { + padding: 8px 12px; + font-size: 0.9rem; + } + + .main-container { + margin-top: 80px; + padding: 1rem; + } + + .page-header h1 { + font-size: 2rem; + } + + .page-header p { + font-size: 1rem; + } + + .card { + padding: 1.5rem; + } + + .form-grid { + grid-template-columns: 1fr; + gap: 1rem; + } + + .charts-grid { + grid-template-columns: 1fr; + gap: 1rem; + } + + .metrics-grid { + grid-template-columns: 1fr; + gap: 1rem; + } + + .comparison-grid { + grid-template-columns: 1fr; + gap: 1rem; + } + + #toast-container { + right: 1rem; + left: 1rem; + top: 80px; + } + + .toast { + max-width: none; + } +} + +@media (max-width: 480px) { + .nav-menu { + flex-direction: column; + gap: 0.5rem; + } + + .nav-link { + padding: 6px 10px; + font-size: 0.8rem; + } + + .btn { + padding: 12px 24px; + font-size: 0.9rem; + } + + .metric-value { + font-size: 1.5rem; + } +} + +/* 综合分析页面特殊样式 */ +.analysis-input-section { + display: flex; + align-items: flex-end; + gap: 1.5rem; + margin-bottom: 1.5rem; + padding: 1.5rem; + background: #f8f9fa; + border-radius: 10px; + border: 1px solid #e9ecef; +} + +.input-wrapper { + flex: 1; + min-width: 200px; +} + +.input-wrapper label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: #000000; +} + +.input-hint { + display: block; + margin-top: 0.25rem; + color: #6c757d; + font-size: 0.875rem; +} + +.analysis-btn { + padding: 12px 24px !important; + font-size: 1rem !important; + font-weight: 600 !important; + min-width: 180px; + height: auto; + white-space: nowrap; + margin-top: 0; +} + +.analysis-info { + padding-top: 1rem; + border-top: 1px solid #e9ecef; +} + +.info-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 1rem; +} + +.info-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + background: #ffffff; + border-radius: 8px; + border: 1px solid #e9ecef; + transition: all 0.2s ease; +} + +.info-item:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + border-color: #000000; +} + +.info-item i { + font-size: 1.2rem; + color: #000000; + width: 20px; + text-align: center; +} + +.info-item span { + font-weight: 600; + color: #000000; +} + +.analysis-desc { + text-align: center; + color: #6c757d; + font-size: 0.9rem; + margin: 0; + font-style: italic; +} + +/* 响应式适配 */ +@media (max-width: 768px) { + .analysis-input-section { + flex-direction: column; + align-items: stretch; + gap: 1rem; + } + + .input-wrapper { + flex: none; + } + + .analysis-btn { + width: 100%; + } + + .info-grid { + grid-template-columns: 1fr; + gap: 0.5rem; + } +} + +/* 股票筛选页面特殊样式 */ +.screening-info-main { + background: #f8f9fa; + border-radius: 10px; + padding: 1.5rem; + border: 1px solid #e9ecef; +} + +.screening-criteria { + display: flex; + justify-content: center; + gap: 2rem; + flex-wrap: wrap; + margin-bottom: 1.5rem; +} + +.criteria-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: #ffffff; + border-radius: 6px; + border: 1px solid #e9ecef; + font-weight: 500; + color: #000000; +} + +.criteria-item i { + color: #007bff; + font-size: 1rem; +} + +.strategy-overview { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid #e9ecef; +} + +.strategy-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 1rem; +} + +.strategy-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 1.25rem; + background: #ffffff; + border-radius: 8px; + border: 1px solid #e9ecef; + transition: all 0.2s ease; +} + +.strategy-item:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + border-color: #000000; +} + +.strategy-item i { + font-size: 1.5rem; + color: #000000; + width: 24px; + text-align: center; +} + +.strategy-info h4 { + margin: 0 0 0.25rem 0; + font-size: 1rem; + font-weight: 600; + color: #000000; +} + +.strategy-info p { + margin: 0; + font-size: 0.875rem; + color: #6c757d; +} + +@media (max-width: 768px) { + .screening-criteria { + flex-direction: column; + gap: 0.75rem; + align-items: center; + } + + .criteria-item { + width: 100%; + justify-content: center; + } + + .strategy-grid { + grid-template-columns: 1fr; + gap: 0.75rem; + } + + .strategy-item { + padding: 1rem; + } + + .strategy-item i { + font-size: 1.25rem; + } +} \ No newline at end of file diff --git a/web/static/js/app.js b/web/static/js/app.js new file mode 100644 index 0000000..01751e1 --- /dev/null +++ b/web/static/js/app.js @@ -0,0 +1,862 @@ +// 全局状态管理 +const AppState = { + currentTab: 'screening', + loading: false +}; + +// 全局图表实例管理 +const ChartInstances = { + scoringChart: null, + industryChart: null +}; + +// DOM 加载完成后初始化 +document.addEventListener('DOMContentLoaded', function() { + initializeApp(); +}); + +// 应用初始化 +function initializeApp() { + setupNavigation(); + setupEventListeners(); + setupRangeSliders(); +} + +// 导航设置 +function setupNavigation() { + const navLinks = document.querySelectorAll('.nav-link'); + const tabContents = document.querySelectorAll('.tab-content'); + + navLinks.forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const targetTab = link.getAttribute('data-tab'); + + // 更新导航状态 + navLinks.forEach(nav => nav.classList.remove('active')); + link.classList.add('active'); + + // 显示对应内容 + tabContents.forEach(content => content.classList.remove('active')); + document.getElementById(targetTab).classList.add('active'); + + AppState.currentTab = targetTab; + }); + }); +} + +// 事件监听器设置 +function setupEventListeners() { + // 股票筛选 + document.getElementById('screening-btn').addEventListener('click', handleScreening); + + // 综合分析 + document.getElementById('analysis-btn').addEventListener('click', handleComprehensiveAnalysis); + + // 键盘事件 + document.addEventListener('keydown', handleKeyPress); +} + +// 范围滑块设置 +function setupRangeSliders() { + // 移除范围滑块设置,因为筛选页面不再使用滑块 +} + +// 股票筛选处理 +let isScreeningInProgress = false; // 防重复提交标志 + +async function handleScreening() { + // 防止重复点击 + if (isScreeningInProgress) { + showToast('筛选正在进行中,请稍候...', 'warning'); + return; + } + + const minScore = 60; // 固定最低评分为60分 + const limit = 50; // 固定结果数量为50个 + + // 综合筛选:使用三种策略综合评估 + const data = { strategy: 'comprehensive', min_score: minScore, limit }; + + isScreeningInProgress = true; + const screeningBtn = document.getElementById('screening-btn'); + const originalText = screeningBtn.innerHTML; + screeningBtn.innerHTML = ' 筛选中...'; + screeningBtn.disabled = true; + + showLoading('正在进行综合筛选...'); + + try { + const response = await fetch('/api/screen', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (response.ok) { + displayScreeningResults(result.results); + showToast('筛选成功!', 'success'); + } else { + throw new Error(result.error || '筛选失败'); + } + } catch (error) { + showToast(`筛选失败: ${error.message}`, 'error'); + } finally { + isScreeningInProgress = false; + screeningBtn.innerHTML = originalText; + screeningBtn.disabled = false; + hideLoading(); + } +} + +// 显示筛选结果 +function displayScreeningResults(results) { + const resultsSection = document.getElementById('screening-results'); + const tableContainer = document.getElementById('screening-table'); + + if (!results || results.length === 0) { + tableContainer.innerHTML = '

未找到符合条件的股票

'; + resultsSection.style.display = 'block'; + return; + } + + // 检查是否是综合评分结果 + const isComprehensive = results[0].value_score !== undefined; + + // 创建表格 + let headers, tableData; + + if (isComprehensive) { + headers = ['股票代码', '名称', '行业', '综合评分', '价值评分', '成长评分', '技术评分', '建议']; + tableData = results.map(stock => [ + stock.ts_code, + stock.name, + stock.industry, + stock.score.toFixed(1), + stock.value_score.toFixed(1), + stock.growth_score.toFixed(1), + stock.technical_score.toFixed(1), + stock.recommendation + ]); + } else { + headers = ['股票代码', '名称', '行业', '评分', '建议']; + tableData = results.map(stock => [ + stock.ts_code, + stock.name, + stock.industry, + stock.score.toFixed(1), + stock.recommendation + ]); + } + + const table = createDataTable(headers, tableData); + + tableContainer.innerHTML = ''; + tableContainer.appendChild(table); + + // 创建图表 + createScoringChart(results.slice(0, 10)); + createIndustryChart(results); + + resultsSection.style.display = 'block'; + resultsSection.scrollIntoView({ behavior: 'smooth' }); +} + +// 综合分析处理 - 合并多策略分析 +async function handleComprehensiveAnalysis() { + const tsCodeInput = document.getElementById('stock-code').value.trim(); + + if (!tsCodeInput) { + showToast('请输入股票代码', 'warning'); + return; + } + + // 格式化股票代码:自动添加交易所后缀 + const tsCode = formatStockCode(tsCodeInput); + + const strategies = ['value', 'growth', 'technical']; + const results = []; + + showLoading('正在进行综合分析...'); + + try { + // 并行获取三种策略的分析结果 + for (const strategy of strategies) { + const response = await fetch('/api/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ts_code: tsCode, strategy }) + }); + + const result = await response.json(); + + if (response.ok) { + results.push({ + strategy: getStrategyName(strategy), + rawStrategy: strategy, + score: result.score, + recommendation: result.recommendation, + data: result + }); + } + } + + if (results.length > 0) { + displayComprehensiveResults(results, tsCode); + showToast('综合分析完成!', 'success'); + } else { + throw new Error('无法获取分析数据'); + } + } catch (error) { + showToast(`分析失败: ${error.message}`, 'error'); + } finally { + hideLoading(); + } +} + +// 显示股票基本信息 +function displayStockBasicInfo(results, tsCode) { + // 从价值策略结果中获取基本信息(因为价值策略通常有最完整的财务数据) + const valueData = results.find(r => r.rawStrategy === 'value') || results[0]; + const stockData = valueData.data; + + // 显示股票代码 + document.getElementById('stock-code-value').textContent = tsCode; + + // 显示股票名称 + const stockName = stockData.name || stockData.stock_name || '--'; + document.getElementById('stock-name-value').textContent = stockName; + + // 显示行业信息 + const industry = stockData.industry || '--'; + document.getElementById('stock-industry-value').textContent = industry; + + // 显示当前价格 + const currentPrice = stockData.current_price || stockData.price; + if (currentPrice) { + document.getElementById('stock-price-value').textContent = `¥${currentPrice.toFixed(2)}`; + } else { + document.getElementById('stock-price-value').textContent = '--'; + } + + // 显示市值 + const marketCap = stockData.market_cap; + if (marketCap) { + const marketCapBillion = (marketCap / 100000000).toFixed(2); + document.getElementById('stock-market-cap-value').textContent = `${marketCapBillion}亿`; + } else { + document.getElementById('stock-market-cap-value').textContent = '--'; + } + + // 显示市盈率 + const peRatio = stockData.financial_ratios?.pe_ratio; + if (peRatio && peRatio !== Infinity && peRatio > 0) { + document.getElementById('stock-pe-value').textContent = peRatio.toFixed(2); + } else { + document.getElementById('stock-pe-value').textContent = '--'; + } +} + +// 显示综合分析结果 +function displayComprehensiveResults(results, tsCode) { + const resultsSection = document.getElementById('analysis-results'); + + // 显示股票基本信息 + displayStockBasicInfo(results, tsCode); + + // 计算综合指标 + const avgScore = results.reduce((sum, r) => sum + r.score, 0) / results.length; + const bestStrategy = results.reduce((best, current) => + current.score > best.score ? current : best + ); + + // 综合建议逻辑 + const buyCount = results.filter(r => r.recommendation === 'BUY').length; + const holdCount = results.filter(r => r.recommendation === 'HOLD').length; + let finalRecommendation; + let confidenceLevel; + + if (buyCount >= 2) { + finalRecommendation = 'BUY'; + confidenceLevel = buyCount === 3 ? 'HIGH' : 'MEDIUM'; + } else if (buyCount + holdCount >= 2) { + finalRecommendation = 'HOLD'; + confidenceLevel = 'MEDIUM'; + } else { + finalRecommendation = 'SELL'; + confidenceLevel = results.filter(r => r.recommendation === 'SELL').length >= 2 ? 'MEDIUM' : 'LOW'; + } + + // 显示综合投资评级 + displayInvestmentRating(avgScore, bestStrategy, finalRecommendation, confidenceLevel); + + // 显示分析总结(包含策略详情表格) + displayAnalysisSummary(results, avgScore, finalRecommendation, bestStrategy); + + // 显示财务指标 (使用价值策略的数据) + const valueData = results.find(r => r.rawStrategy === 'value'); + if (valueData && valueData.data.financial_ratios) { + displayFinancialRatios(valueData.data.financial_ratios); + } + + // 检查是否有技术分析的交易信号 + const technicalData = results.find(r => r.rawStrategy === 'technical'); + if (technicalData && technicalData.data.trading_signals) { + displayTradingSignals(technicalData.data.trading_signals); + document.getElementById('trading-signals-section').style.display = 'block'; + } else { + document.getElementById('trading-signals-section').style.display = 'none'; + } + + resultsSection.style.display = 'block'; + resultsSection.scrollIntoView({ behavior: 'smooth' }); +} + +// 显示综合投资评级 +function displayInvestmentRating(avgScore, bestStrategy, finalRecommendation, confidenceLevel) { + // 显示综合评级指标 + document.getElementById('best-strategy-value').textContent = bestStrategy.strategy; + document.getElementById('avg-score-value').textContent = avgScore.toFixed(1) + '分'; + document.getElementById('final-recommendation-value').textContent = finalRecommendation; + document.getElementById('confidence-level-value').textContent = + confidenceLevel === 'HIGH' ? '高' : + confidenceLevel === 'MEDIUM' ? '中' : '低'; +} + +// 显示分析总结 +function displayAnalysisSummary(results, avgScore, finalRecommendation, bestStrategy) { + const container = document.getElementById('summary-content'); + + let summaryHtml = ` +

综合评分:${avgScore.toFixed(1)}分

+

投资建议:${finalRecommendation}

+

最佳策略:${bestStrategy.strategy} (${bestStrategy.score.toFixed(1)}分)

+
+

各策略分析:

+ '; + + // 添加投资建议说明 + if (finalRecommendation === 'BUY') { + summaryHtml += '

建议买入:多数策略看好,具有较好投资价值

'; + } else if (finalRecommendation === 'HOLD') { + summaryHtml += '

建议持有:策略结果分化,建议持有观望

'; + } else { + summaryHtml += '

建议卖出:多数策略不看好,存在较大风险

'; + } + + // 添加策略详细评分表格 + summaryHtml += ` +
+
+
+ `; + + container.innerHTML = summaryHtml; + + // 创建策略对比表格 + createStrategyComparisonTable(results, 'strategy-comparison-table-inline'); +} + +// 创建策略对比表格 +function createStrategyComparisonTable(results, containerId = 'strategy-comparison-table') { + const container = document.getElementById(containerId); + + const headers = ['投资策略', '评分', '投资建议', '策略特点']; + const rows = results.map(r => [ + r.strategy, + r.score.toFixed(1) + '分', + r.recommendation, + getStrategyDescription(r.rawStrategy) + ]); + + const table = createDataTable(headers, rows); + container.innerHTML = ''; + container.appendChild(table); +} + +// 获取策略描述 +function getStrategyDescription(strategy) { + const descriptions = { + 'value': '注重估值合理性,适合稳健投资', + 'growth': '关注成长潜力,适合追求高收益', + 'technical': '基于技术指标,适合短期交易' + }; + return descriptions[strategy] || ''; +} + +// 显示财务指标 +function displayFinancialRatios(ratios) { + const container = document.getElementById('financial-ratios'); + const ratioItems = [ + { key: 'roe', label: 'ROE (净资产收益率)', suffix: '%' }, + { key: 'roa', label: 'ROA (总资产收益率)', suffix: '%' }, + { key: 'gross_margin', label: '毛利率', suffix: '%' }, + { key: 'net_margin', label: '净利率', suffix: '%' }, + { key: 'current_ratio', label: '流动比率', suffix: '' }, + { key: 'debt_ratio', label: '负债比率', suffix: '%' } + ]; + + const grid = document.createElement('div'); + grid.className = 'metrics-grid'; + + ratioItems.forEach(item => { + if (ratios[item.key] !== undefined) { + const card = document.createElement('div'); + card.className = 'metric-card'; + card.innerHTML = ` +
${ratios[item.key].toFixed(2)}${item.suffix}
+
${item.label}
+ `; + grid.appendChild(card); + } + }); + + container.innerHTML = ''; + container.appendChild(grid); +} + +// 显示交易信号分析 +function displayTradingSignals(tradingSignals) { + const entrySignals = tradingSignals.entry_signals || {}; + const exitSignals = tradingSignals.exit_signals || {}; + const stopLossTakeProfit = tradingSignals.stop_loss_take_profit || {}; + const tradingAdvice = tradingSignals.trading_advice || {}; + const marketTiming = tradingSignals.market_timing || {}; + + // 显示主要信号指标 + document.getElementById('entry-signal-value').textContent = + getSignalDisplayText(entrySignals.overall_signal) + ` (${entrySignals.signal_strength || 0}%)`; + + document.getElementById('exit-signal-value').textContent = + getSignalDisplayText(exitSignals.overall_signal) + ` (${exitSignals.signal_strength || 0}%)`; + + document.getElementById('trading-action-value').textContent = + getActionDisplayText(tradingAdvice.action); + + document.getElementById('risk-level-value').textContent = + getRiskDisplayText(tradingAdvice.risk_assessment); + + // 显示买入价格和卖出价格建议 + const currentPrice = tradingSignals.current_price || 0; + const entryPriceData = tradingSignals.entry_price || {}; + const exitPriceData = tradingSignals.exit_price || {}; + + // 根据操作建议显示相应的价格 + const action = tradingAdvice.action; + + document.getElementById('entry-price-value').textContent = + (action === 'BUY' || action === 'CONSIDER_BUY') && entryPriceData.recommended ? + `¥${entryPriceData.recommended}` : + (action === 'BUY' || action === 'CONSIDER_BUY') && currentPrice > 0 ? + `¥${currentPrice.toFixed(2)}` : '--'; + + document.getElementById('exit-price-value').textContent = + (action === 'SELL') && exitPriceData.recommended ? + `¥${exitPriceData.recommended}` : + (action === 'SELL') && currentPrice > 0 ? + `¥${currentPrice.toFixed(2)}` : '--'; + + // 显示止盈止损信息(只有在非观望状态时才显示) + const stopLoss = stopLossTakeProfit.stop_loss || {}; + const takeProfit = stopLossTakeProfit.take_profit || {}; + const isWaitAction = tradingAdvice.action === 'WAIT'; + + document.getElementById('stop-loss-value').textContent = + (isWaitAction || !stopLoss.recommended) ? '--' : `¥${stopLoss.recommended}`; + + document.getElementById('take-profit-value').textContent = + (isWaitAction || !takeProfit.recommended) ? '--' : `¥${takeProfit.recommended}`; + + document.getElementById('risk-reward-ratio-value').textContent = + (isWaitAction || !stopLossTakeProfit.risk_reward_ratio) ? '--' : `1:${stopLossTakeProfit.risk_reward_ratio}`; + + document.getElementById('position-size-value').textContent = + (isWaitAction || !stopLossTakeProfit.position_sizing_suggestion) ? '--' : + `${stopLossTakeProfit.position_sizing_suggestion.suggested_position}%`; + + // 显示详细信号分析 + displaySignalsDetails(entrySignals, exitSignals, tradingAdvice, marketTiming); +} + +// 显示信号详情 +function displaySignalsDetails(entrySignals, exitSignals, tradingAdvice, marketTiming) { + const container = document.getElementById('signals-content'); + + let detailsHtml = '
'; + + // 买入信号详情 + if (entrySignals.entry_reasons && entrySignals.entry_reasons.length > 0) { + detailsHtml += '
'; + detailsHtml += '
📈 买入信号原因
'; + detailsHtml += '
'; + } + + // 卖出信号详情 + if (exitSignals.exit_reasons && exitSignals.exit_reasons.length > 0) { + detailsHtml += '
'; + detailsHtml += '
📉 卖出信号原因
'; + detailsHtml += '
'; + } + + // 交易建议详情 + if (tradingAdvice.reasoning && tradingAdvice.reasoning.length > 0) { + detailsHtml += '
'; + detailsHtml += '
🎯 交易建议理由
'; + detailsHtml += '
'; + } + + // 市场时机分析 + if (marketTiming.recommendations && marketTiming.recommendations.length > 0) { + detailsHtml += '
'; + detailsHtml += '
⏰ 市场时机分析
'; + detailsHtml += `

市场状态: ${getMarketConditionText(marketTiming.market_condition)}

`; + detailsHtml += `

流动性: ${getLiquidityText(marketTiming.liquidity)}

`; + detailsHtml += `

趋势强度: ${getTrendStrengthText(marketTiming.trend_strength)}

`; + detailsHtml += '
'; + } + + // 风险提示 + const warningFlags = entrySignals.warning_flags || []; + const riskFlags = exitSignals.risk_flags || []; + if (warningFlags.length > 0 || riskFlags.length > 0) { + detailsHtml += '
'; + detailsHtml += '
⚠️ 风险提示
'; + detailsHtml += '
'; + } + + detailsHtml += '
'; + + container.innerHTML = detailsHtml; +} + +// 信号显示文本转换函数 +function getSignalDisplayText(signal) { + const signalMap = { + 'STRONG_BUY': '强烈买入', + 'BUY': '买入', + 'WEAK_BUY': '弱买入', + 'NEUTRAL': '中性', + 'WEAK_SELL': '弱卖出', + 'SELL': '卖出', + 'STRONG_SELL': '强烈卖出' + }; + return signalMap[signal] || '未知'; +} + +function getActionDisplayText(action) { + const actionMap = { + 'BUY': '买入', + 'SELL': '卖出', + 'CONSIDER_BUY': '考虑买入', + 'WAIT': '观望', + 'HOLD': '持有' + }; + return actionMap[action] || '观望'; +} + +function getRiskDisplayText(risk) { + const riskMap = { + 'LOW': '低风险', + 'MEDIUM': '中等风险', + 'HIGH': '高风险' + }; + return riskMap[risk] || '中等风险'; +} + +function getMarketConditionText(condition) { + const conditionMap = { + 'NORMAL': '正常', + 'VOLATILE': '高波动', + 'QUIET': '平静' + }; + return conditionMap[condition] || '正常'; +} + +function getLiquidityText(liquidity) { + const liquidityMap = { + 'POOR': '流动性差', + 'GOOD': '流动性良好', + 'EXCELLENT': '流动性优秀' + }; + return liquidityMap[liquidity] || '流动性良好'; +} + +function getTrendStrengthText(strength) { + const strengthMap = { + 'WEAK': '趋势较弱', + 'MEDIUM': '趋势中等', + 'STRONG': '趋势较强' + }; + return strengthMap[strength] || '趋势中等'; +} + +// 策略对比处理 - 已移除,合并到综合分析中 +// 此函数已被 handleComprehensiveAnalysis 替代 + +// 显示对比结果 - 已移除,合并到综合分析中 +// 此函数已被 displayComprehensiveResults 替代 + +// 工具函数 +function formatStockCode(code) { + // 移除所有空格和非数字字符,保留原有后缀 + const cleanCode = code.replace(/\s+/g, '').toUpperCase(); + + // 如果已经包含交易所后缀,直接返回 + if (cleanCode.includes('.SZ') || cleanCode.includes('.SH')) { + return cleanCode; + } + + // 只包含数字的情况,自动判断交易所 + const numericCode = cleanCode.replace(/[^0-9]/g, ''); + + if (numericCode.length === 6) { + // 深交所:000xxx, 002xxx, 300xxx + if (numericCode.startsWith('000') || numericCode.startsWith('002') || numericCode.startsWith('300')) { + return numericCode + '.SZ'; + } + // 上交所:600xxx, 601xxx, 603xxx, 688xxx + else if (numericCode.startsWith('60') || numericCode.startsWith('688')) { + return numericCode + '.SH'; + } + } + + // 默认返回原输入加.SZ(深交所较多) + return numericCode + '.SZ'; +} + +function getStrategyName(strategy) { + const names = { + 'value': '价值投资', + 'growth': '成长投资', + 'technical': '技术分析' + }; + return names[strategy] || strategy; +} + +function getStrategyIcon(strategy) { + const icons = { + '价值投资': '💰', + '成长投资': '🚀', + '技术分析': '📈' + }; + return icons[strategy] || '📊'; +} + +// 创建数据表格 +function createDataTable(headers, rows) { + const table = document.createElement('table'); + table.className = 'data-table'; + + // 创建表头 + const thead = document.createElement('thead'); + const headerRow = document.createElement('tr'); + headers.forEach(header => { + const th = document.createElement('th'); + th.textContent = header; + headerRow.appendChild(th); + }); + thead.appendChild(headerRow); + table.appendChild(thead); + + // 创建表体 + const tbody = document.createElement('tbody'); + rows.forEach(row => { + const tr = document.createElement('tr'); + row.forEach(cell => { + const td = document.createElement('td'); + td.textContent = cell; + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + table.appendChild(tbody); + + return table; +} + +// 图表创建函数 +function createScoringChart(data) { + const ctx = document.getElementById('scoring-chart').getContext('2d'); + + // 销毁已存在的图表 + if (ChartInstances.scoringChart) { + ChartInstances.scoringChart.destroy(); + } + + ChartInstances.scoringChart = new Chart(ctx, { + type: 'bar', + data: { + labels: data.map(item => item.name), + datasets: [{ + label: '评分', + data: data.map(item => item.score), + backgroundColor: '#000000', /* 纯黑色 */ + borderColor: '#000000', + borderWidth: 1, + borderRadius: 4 + }] + }, + options: { + responsive: true, + plugins: { + title: { display: true, text: '股票评分排行' }, + legend: { display: false } + }, + scales: { + y: { beginAtZero: true, max: 100 } + } + } + }); +} + +function createIndustryChart(data) { + const ctx = document.getElementById('industry-chart').getContext('2d'); + + // 销毁已存在的图表 + if (ChartInstances.industryChart) { + ChartInstances.industryChart.destroy(); + } + + const industries = {}; + + data.forEach(item => { + industries[item.industry] = (industries[item.industry] || 0) + 1; + }); + + ChartInstances.industryChart = new Chart(ctx, { + type: 'doughnut', + data: { + labels: Object.keys(industries), + datasets: [{ + data: Object.values(industries), + backgroundColor: [ + '#000000', '#333333', '#666666', '#999999', + '#cccccc', '#444444', '#777777', '#aaaaaa' + ] + }] + }, + options: { + responsive: true, + plugins: { + title: { display: true, text: '行业分布' } + } + } + }); +} + + + +// UI 辅助函数 +function showLoading(message = '加载中...') { + const loading = document.getElementById('loading'); + loading.querySelector('p').textContent = message; + loading.style.display = 'flex'; + AppState.loading = true; +} + +function hideLoading() { + document.getElementById('loading').style.display = 'none'; + AppState.loading = false; +} + +function showToast(message, type = 'info') { + const container = document.getElementById('toast-container'); + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + + const icon = getToastIcon(type); + toast.innerHTML = ` +
+ ${icon} + ${message} +
+ `; + + container.appendChild(toast); + + // 自动移除 + setTimeout(() => { + toast.style.animation = 'slideOutRight 0.3s ease'; + setTimeout(() => container.removeChild(toast), 300); + }, 3000); +} + +function getToastIcon(type) { + const icons = { + success: '✅', + error: '❌', + warning: '⚠️', + info: 'ℹ️' + }; + return icons[type] || icons.info; +} + +// 键盘快捷键处理 +function handleKeyPress(e) { + if (AppState.loading) return; + + // Ctrl + 数字键切换标签 + if (e.ctrlKey) { + switch(e.key) { + case '1': + e.preventDefault(); + document.querySelector('[data-tab="screening"]').click(); + break; + case '2': + e.preventDefault(); + document.querySelector('[data-tab="analysis"]').click(); + break; + } + } + + // Enter 键执行当前标签的主要操作 + if (e.key === 'Enter' && e.ctrlKey) { + e.preventDefault(); + switch(AppState.currentTab) { + case 'screening': + document.getElementById('screening-btn').click(); + break; + case 'analysis': + document.getElementById('analysis-btn').click(); + break; + } + } +} \ No newline at end of file diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..6574385 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,338 @@ + + + + + + AI 金融分析平台 + + + + + + + + + +
+ +
+ + +
+
+

筛选参数

+
+
+
+
+
+ + 综合评分 ≥ 60分 +
+
+ + 三维度综合评估 +
+
+ + 最多50只股票 +
+
+
+
+
+ +
+

价值投资

+

财务指标、估值分析

+
+
+
+ +
+

成长投资

+

增长潜力、盈利能力

+
+
+
+ +
+

技术分析

+

趋势判断、交易时机

+
+
+
+
+
+ +
+
+ + +
+ + +
+ + +
+
+

分析参数

+
+
+
+
+ + + 输入6位股票代码,系统将自动识别交易所 +
+ +
+ +
+
+
+ + 价值投资 +
+
+ + 成长投资 +
+
+ + 技术分析 +
+
+

三维度专业分析,为您提供全面的投资决策支持

+
+
+
+ + +
+
+ + + + + +
+ + + + + + \ No newline at end of file