first commit
This commit is contained in:
commit
8c6ae691ef
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(mkdir:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
9
.env.example
Normal file
9
.env.example
Normal file
@ -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
|
||||
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@ -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/
|
||||
146
README.md
Normal file
146
README.md
Normal file
@ -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
|
||||
19
config/config.py
Normal file
19
config/config.py
Normal file
@ -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')
|
||||
333
main.py
Normal file
333
main.py
Normal file
@ -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
|
||||
)
|
||||
15
requirements.txt
Normal file
15
requirements.txt
Normal file
@ -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
|
||||
4
src/analysis/__init__.py
Normal file
4
src/analysis/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from .financial_indicators import FinancialIndicators
|
||||
from .technical_indicators import TechnicalIndicators
|
||||
|
||||
__all__ = ['FinancialIndicators', 'TechnicalIndicators']
|
||||
252
src/analysis/financial_indicators.py
Normal file
252
src/analysis/financial_indicators.py
Normal file
@ -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 {}
|
||||
284
src/analysis/technical_indicators.py
Normal file
284
src/analysis/technical_indicators.py
Normal file
@ -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
|
||||
461
src/analysis/trading_signals.py
Normal file
461
src/analysis/trading_signals.py
Normal file
@ -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}%波动率计算'
|
||||
}
|
||||
5
src/data/__init__.py
Normal file
5
src/data/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from .tushare_client import TushareClient
|
||||
from .database import Database
|
||||
from .cache import RedisCache
|
||||
|
||||
__all__ = ['TushareClient', 'Database', 'RedisCache']
|
||||
64
src/data/cache.py
Normal file
64
src/data/cache.py
Normal file
@ -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
|
||||
86
src/data/database.py
Normal file
86
src/data/database.py
Normal file
@ -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()
|
||||
142
src/data/tushare_client.py
Normal file
142
src/data/tushare_client.py
Normal file
@ -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()
|
||||
6
src/strategies/__init__.py
Normal file
6
src/strategies/__init__.py
Normal file
@ -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']
|
||||
45
src/strategies/base_strategy.py
Normal file
45
src/strategies/base_strategy.py
Normal file
@ -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)
|
||||
176
src/strategies/growth_strategy.py
Normal file
176
src/strategies/growth_strategy.py
Normal file
@ -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成分股中寻找高增长、高盈利、有创新能力的成长股'
|
||||
}
|
||||
519
src/strategies/technical_strategy.py
Normal file
519
src/strategies/technical_strategy.py
Normal file
@ -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成分股中寻找技术面良好、趋势向上、成交活跃的股票'
|
||||
}
|
||||
179
src/strategies/value_strategy.py
Normal file
179
src/strategies/value_strategy.py
Normal file
@ -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成分股中寻找被低估、财务稳健、盈利能力强的价值股'
|
||||
}
|
||||
3
src/utils/__init__.py
Normal file
3
src/utils/__init__.py
Normal file
@ -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']
|
||||
54
src/utils/helpers.py
Normal file
54
src/utils/helpers.py
Normal file
@ -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'])
|
||||
23
start_web.sh
Executable file
23
start_web.sh
Executable file
@ -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
|
||||
108
web/README.md
Normal file
108
web/README.md
Normal file
@ -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 # 交互脚本
|
||||
```
|
||||
|
||||
现在你拥有了一个真正现代化的股票分析平台!🎉
|
||||
35
web/app.py
Normal file
35
web/app.py
Normal file
@ -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)
|
||||
2
web/requirements.txt
Normal file
2
web/requirements.txt
Normal file
@ -0,0 +1,2 @@
|
||||
Flask==2.3.3
|
||||
requests==2.31.0
|
||||
743
web/static/css/style.css
Normal file
743
web/static/css/style.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
862
web/static/js/app.js
Normal file
862
web/static/js/app.js
Normal file
@ -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 = '<i class="fas fa-spinner fa-spin"></i> 筛选中...';
|
||||
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 = '<p class="text-center">未找到符合条件的股票</p>';
|
||||
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 = `
|
||||
<p><strong>综合评分:</strong>${avgScore.toFixed(1)}分</p>
|
||||
<p><strong>投资建议:</strong>${finalRecommendation}</p>
|
||||
<p><strong>最佳策略:</strong>${bestStrategy.strategy} (${bestStrategy.score.toFixed(1)}分)</p>
|
||||
<br>
|
||||
<p><strong>各策略分析:</strong></p>
|
||||
<ul>
|
||||
`;
|
||||
|
||||
results.forEach(result => {
|
||||
let analysis = '';
|
||||
if (result.score >= 65) {
|
||||
analysis = '表现优秀,符合投资标准';
|
||||
} else if (result.score >= 45) {
|
||||
analysis = '表现一般,可考虑关注';
|
||||
} else {
|
||||
analysis = '表现较差,需谨慎考虑';
|
||||
}
|
||||
|
||||
summaryHtml += `<li><strong>${result.strategy}:</strong>${result.score.toFixed(1)}分,${analysis}</li>`;
|
||||
});
|
||||
|
||||
summaryHtml += '</ul>';
|
||||
|
||||
// 添加投资建议说明
|
||||
if (finalRecommendation === 'BUY') {
|
||||
summaryHtml += '<p style="color: #22c55e; margin-top: 1rem;"><strong>建议买入:</strong>多数策略看好,具有较好投资价值</p>';
|
||||
} else if (finalRecommendation === 'HOLD') {
|
||||
summaryHtml += '<p style="color: #f59e0b; margin-top: 1rem;"><strong>建议持有:</strong>策略结果分化,建议持有观望</p>';
|
||||
} else {
|
||||
summaryHtml += '<p style="color: #ef4444; margin-top: 1rem;"><strong>建议卖出:</strong>多数策略不看好,存在较大风险</p>';
|
||||
}
|
||||
|
||||
// 添加策略详细评分表格
|
||||
summaryHtml += `
|
||||
<div style="margin-top: 1.5rem;">
|
||||
<div id="strategy-comparison-table-inline"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="metric-value">${ratios[item.key].toFixed(2)}${item.suffix}</div>
|
||||
<div class="metric-label">${item.label}</div>
|
||||
`;
|
||||
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 = '<div class="signals-analysis">';
|
||||
|
||||
// 买入信号详情
|
||||
if (entrySignals.entry_reasons && entrySignals.entry_reasons.length > 0) {
|
||||
detailsHtml += '<div class="signal-section">';
|
||||
detailsHtml += '<h5 style="color: #22c55e; margin-bottom: 0.5rem;">📈 买入信号原因</h5>';
|
||||
detailsHtml += '<ul>';
|
||||
entrySignals.entry_reasons.forEach(reason => {
|
||||
detailsHtml += `<li>${reason}</li>`;
|
||||
});
|
||||
detailsHtml += '</ul></div>';
|
||||
}
|
||||
|
||||
// 卖出信号详情
|
||||
if (exitSignals.exit_reasons && exitSignals.exit_reasons.length > 0) {
|
||||
detailsHtml += '<div class="signal-section">';
|
||||
detailsHtml += '<h5 style="color: #ef4444; margin-bottom: 0.5rem;">📉 卖出信号原因</h5>';
|
||||
detailsHtml += '<ul>';
|
||||
exitSignals.exit_reasons.forEach(reason => {
|
||||
detailsHtml += `<li>${reason}</li>`;
|
||||
});
|
||||
detailsHtml += '</ul></div>';
|
||||
}
|
||||
|
||||
// 交易建议详情
|
||||
if (tradingAdvice.reasoning && tradingAdvice.reasoning.length > 0) {
|
||||
detailsHtml += '<div class="signal-section">';
|
||||
detailsHtml += '<h5 style="color: #3b82f6; margin-bottom: 0.5rem;">🎯 交易建议理由</h5>';
|
||||
detailsHtml += '<ul>';
|
||||
tradingAdvice.reasoning.forEach(reason => {
|
||||
detailsHtml += `<li>${reason}</li>`;
|
||||
});
|
||||
detailsHtml += '</ul></div>';
|
||||
}
|
||||
|
||||
// 市场时机分析
|
||||
if (marketTiming.recommendations && marketTiming.recommendations.length > 0) {
|
||||
detailsHtml += '<div class="signal-section">';
|
||||
detailsHtml += '<h5 style="color: #f59e0b; margin-bottom: 0.5rem;">⏰ 市场时机分析</h5>';
|
||||
detailsHtml += `<p><strong>市场状态:</strong> ${getMarketConditionText(marketTiming.market_condition)}</p>`;
|
||||
detailsHtml += `<p><strong>流动性:</strong> ${getLiquidityText(marketTiming.liquidity)}</p>`;
|
||||
detailsHtml += `<p><strong>趋势强度:</strong> ${getTrendStrengthText(marketTiming.trend_strength)}</p>`;
|
||||
detailsHtml += '<ul>';
|
||||
marketTiming.recommendations.forEach(rec => {
|
||||
detailsHtml += `<li>${rec}</li>`;
|
||||
});
|
||||
detailsHtml += '</ul></div>';
|
||||
}
|
||||
|
||||
// 风险提示
|
||||
const warningFlags = entrySignals.warning_flags || [];
|
||||
const riskFlags = exitSignals.risk_flags || [];
|
||||
if (warningFlags.length > 0 || riskFlags.length > 0) {
|
||||
detailsHtml += '<div class="signal-section">';
|
||||
detailsHtml += '<h5 style="color: #dc2626; margin-bottom: 0.5rem;">⚠️ 风险提示</h5>';
|
||||
detailsHtml += '<ul>';
|
||||
[...warningFlags, ...riskFlags].forEach(flag => {
|
||||
detailsHtml += `<li style="color: #dc2626;">${flag}</li>`;
|
||||
});
|
||||
detailsHtml += '</ul></div>';
|
||||
}
|
||||
|
||||
detailsHtml += '</div>';
|
||||
|
||||
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 = `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span>${icon}</span>
|
||||
<span>${message}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
338
web/templates/index.html
Normal file
338
web/templates/index.html
Normal file
@ -0,0 +1,338 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI 金融分析平台</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="/static/css/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<!-- 导航栏 -->
|
||||
<nav class="navbar">
|
||||
<div class="nav-container">
|
||||
<div class="nav-brand">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
<span>AI 金融分析平台</span>
|
||||
</div>
|
||||
<div class="nav-menu">
|
||||
<a href="#screening" class="nav-link active" data-tab="screening">
|
||||
<i class="fas fa-filter"></i>
|
||||
股票筛选
|
||||
</a>
|
||||
<a href="#analysis" class="nav-link" data-tab="analysis">
|
||||
<i class="fas fa-chart-bar"></i>
|
||||
综合分析
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 主容器 -->
|
||||
<main class="main-container">
|
||||
<!-- 股票筛选页面 -->
|
||||
<section id="screening" class="tab-content active">
|
||||
<div class="page-header">
|
||||
<h1><i class="fas fa-filter"></i> 股票筛选</h1>
|
||||
<p>运用专业投资策略,从沪深300成分股中筛选出最具投资价值的标的</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>筛选参数</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="screening-info-main">
|
||||
<div class="screening-criteria">
|
||||
<div class="criteria-item">
|
||||
<i class="fas fa-star"></i>
|
||||
<span>综合评分 ≥ 60分</span>
|
||||
</div>
|
||||
<div class="criteria-item">
|
||||
<i class="fas fa-chart-bar"></i>
|
||||
<span>三维度综合评估</span>
|
||||
</div>
|
||||
<div class="criteria-item">
|
||||
<i class="fas fa-list"></i>
|
||||
<span>最多50只股票</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="strategy-overview">
|
||||
<div class="strategy-grid">
|
||||
<div class="strategy-item">
|
||||
<i class="fas fa-dollar-sign"></i>
|
||||
<div class="strategy-info">
|
||||
<h4>价值投资</h4>
|
||||
<p>财务指标、估值分析</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="strategy-item">
|
||||
<i class="fas fa-rocket"></i>
|
||||
<div class="strategy-info">
|
||||
<h4>成长投资</h4>
|
||||
<p>增长潜力、盈利能力</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="strategy-item">
|
||||
<i class="fas fa-chart-area"></i>
|
||||
<div class="strategy-info">
|
||||
<h4>技术分析</h4>
|
||||
<p>趋势判断、交易时机</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="screening-btn" class="btn btn-primary">
|
||||
<i class="fas fa-search"></i>
|
||||
开始智能筛选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="screening-results" class="results-section" style="display: none;">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>筛选结果</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="screening-table"></div>
|
||||
<div class="charts-grid">
|
||||
<div class="chart-container">
|
||||
<canvas id="scoring-chart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="industry-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 综合分析页面 -->
|
||||
<section id="analysis" class="tab-content">
|
||||
<div class="page-header">
|
||||
<h1><i class="fas fa-chart-bar"></i> 综合分析</h1>
|
||||
<p>从价值投资、成长投资、技术分析三个维度全面评估股票投资价值</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>分析参数</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="analysis-input-section">
|
||||
<div class="input-wrapper">
|
||||
<label for="stock-code">股票代码</label>
|
||||
<input type="text" id="stock-code" class="form-input" placeholder="如: 000001" value="000001">
|
||||
<small class="input-hint">输入6位股票代码,系统将自动识别交易所</small>
|
||||
</div>
|
||||
<button id="analysis-btn" class="btn btn-primary analysis-btn">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
开始综合分析
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="analysis-info">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<i class="fas fa-dollar-sign"></i>
|
||||
<span>价值投资</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<i class="fas fa-rocket"></i>
|
||||
<span>成长投资</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<i class="fas fa-chart-area"></i>
|
||||
<span>技术分析</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="analysis-desc">三维度专业分析,为您提供全面的投资决策支持</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="analysis-results" class="results-section" style="display: none;">
|
||||
<!-- 股票基本信息和财务指标 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>股票基本信息</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">🏷️</div>
|
||||
<div class="metric-value" id="stock-code-value">--</div>
|
||||
<div class="metric-label">股票代码</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">🏢</div>
|
||||
<div class="metric-value" id="stock-name-value">--</div>
|
||||
<div class="metric-label">股票名称</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">🏭</div>
|
||||
<div class="metric-value" id="stock-industry-value">--</div>
|
||||
<div class="metric-label">所属行业</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">💰</div>
|
||||
<div class="metric-value" id="stock-price-value">--</div>
|
||||
<div class="metric-label">当前价格</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">📊</div>
|
||||
<div class="metric-value" id="stock-market-cap-value">--</div>
|
||||
<div class="metric-label">市值</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">📈</div>
|
||||
<div class="metric-value" id="stock-pe-value">--</div>
|
||||
<div class="metric-label">市盈率</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 财务指标 -->
|
||||
<div style="margin-top: 1.5rem;">
|
||||
<h4>关键财务指标</h4>
|
||||
<div id="financial-ratios"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 综合投资评级 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>综合投资评级</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">🎯</div>
|
||||
<div class="metric-value" id="best-strategy-value">--</div>
|
||||
<div class="metric-label">最佳策略</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">📊</div>
|
||||
<div class="metric-value" id="avg-score-value">--</div>
|
||||
<div class="metric-label">综合评分</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">💡</div>
|
||||
<div class="metric-value" id="final-recommendation-value">--</div>
|
||||
<div class="metric-label">投资建议</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">📈</div>
|
||||
<div class="metric-value" id="confidence-level-value">--</div>
|
||||
<div class="metric-label">置信度</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 投资分析总结 -->
|
||||
<div style="margin-top: 1.5rem;">
|
||||
<h4>投资分析总结</h4>
|
||||
<div id="summary-content"></div>
|
||||
|
||||
<!-- 策略评分详情表格 -->
|
||||
<div style="margin-top: 1.5rem;">
|
||||
<h5>各策略详细评分</h5>
|
||||
<div id="strategy-comparison-table"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 交易信号分析 (仅技术分析策略显示) -->
|
||||
<div id="trading-signals-section" class="card" style="display: none;">
|
||||
<div class="card-header">
|
||||
<h3>交易时机分析</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 交易信号概览 -->
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">📈</div>
|
||||
<div class="metric-value" id="entry-signal-value">--</div>
|
||||
<div class="metric-label">买入信号</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">📉</div>
|
||||
<div class="metric-value" id="exit-signal-value">--</div>
|
||||
<div class="metric-label">卖出信号</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">🎯</div>
|
||||
<div class="metric-value" id="trading-action-value">--</div>
|
||||
<div class="metric-label">操作建议</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">⚠️</div>
|
||||
<div class="metric-value" id="risk-level-value">--</div>
|
||||
<div class="metric-label">风险评级</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">💰</div>
|
||||
<div class="metric-value" id="entry-price-value">--</div>
|
||||
<div class="metric-label">建议买入价</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">💸</div>
|
||||
<div class="metric-value" id="exit-price-value">--</div>
|
||||
<div class="metric-label">建议卖出价</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">🛡️</div>
|
||||
<div class="metric-value" id="stop-loss-value">--</div>
|
||||
<div class="metric-label">建议止损</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">🎯</div>
|
||||
<div class="metric-value" id="take-profit-value">--</div>
|
||||
<div class="metric-label">建议止盈</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">⚖️</div>
|
||||
<div class="metric-value" id="risk-reward-ratio-value">--</div>
|
||||
<div class="metric-label">风险收益比</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">📊</div>
|
||||
<div class="metric-value" id="position-size-value">--</div>
|
||||
<div class="metric-label">建议仓位</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 交易详情 -->
|
||||
<div style="margin-top: 1.5rem;">
|
||||
<h4>交易信号详情</h4>
|
||||
<div id="trading-signals-details" class="card" style="margin-top: 0.5rem;">
|
||||
<div class="card-body">
|
||||
<div id="signals-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- 加载动画 -->
|
||||
<div id="loading" class="loading-overlay" style="display: none;">
|
||||
<div class="spinner"></div>
|
||||
<p>正在处理中...</p>
|
||||
</div>
|
||||
|
||||
<!-- Toast 通知 -->
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<!-- 脚本 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user