fix: 增加数据库缓存改造相关方案

master
Lxy 2 months ago
parent e26b7960fb
commit 639753f593

@ -0,0 +1,218 @@
# -*- coding: utf-8 -*-
"""
===================================
Market data endpoints
===================================
Responsibilities:
1. Provide market indices data
2. Provide sector momentum data
3. Provide stock momentum recommendations
4. Provide new high/low stocks
5. Provide price distribution
"""
import logging
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Query, Depends
from api.v1.schemas.market import (
MarketIndex,
UpDownStats,
SectorMomentum,
StockMomentum,
NewHighLowStock,
PriceDistribution,
SentimentIndicator,
MarketOverview,
SectorListResponse,
StockMomentumResponse,
NewHighLowResponse,
PriceDistributionResponse,
KLineChartResponse,
)
from api.v1.schemas.common import ErrorResponse
from src.services.market_service import MarketService
logger = logging.getLogger(__name__)
router = APIRouter()
def get_market_service() -> MarketService:
"""Dependency injection for MarketService"""
return MarketService()
@router.get(
"/indices",
response_model=List[MarketIndex],
responses={
200: {"description": "Market indices data"},
500: {"description": "Server error", "model": ErrorResponse},
},
summary="Get market indices",
description="Get major market indices (Shanghai, Shenzhen, ChiNext, STAR50)"
)
def get_indices(
service: MarketService = Depends(get_market_service)
) -> List[MarketIndex]:
"""
Get market indices data
Returns major market indices including:
- Shanghai Composite Index (000001.SH)
- Shenzhen Component Index (399001.SZ)
- ChiNext Index (399006.SZ)
- STAR50 Index (000698.SH)
"""
try:
indices = service.get_indices()
return indices
except Exception as e:
logger.error(f"Failed to get market indices: {e}", exc_info=True)
return []
@router.get(
"/updown-stats",
response_model=UpDownStats,
responses={
200: {"description": "Up/down statistics"},
500: {"description": "Server error", "model": ErrorResponse},
},
summary="Get up/down statistics",
description="Get market up/down statistics including limit up/down counts"
)
def get_updown_stats(
service: MarketService = Depends(get_market_service)
) -> UpDownStats:
"""
Get up/down statistics
Returns statistics about:
- Number of rising stocks
- Number of falling stocks
- Number of flat stocks
- Limit up/down counts
"""
try:
stats = service.get_updown_stats()
return stats
except Exception as e:
logger.error(f"Failed to get updown stats: {e}", exc_info=True)
return UpDownStats(
up_count=0,
down_count=0,
flat_count=0,
limit_up_count=0,
limit_down_count=0,
total_count=0
)
@router.get(
"/price-distribution",
response_model=PriceDistributionResponse,
responses={
200: {"description": "Price distribution"},
500: {"description": "Server error", "model": ErrorResponse},
},
summary="Get price distribution",
description="Get stock price distribution across different price ranges"
)
def get_price_distribution(
service: MarketService = Depends(get_market_service)
) -> PriceDistributionResponse:
"""
Get price distribution
Returns distribution of stocks across price ranges:
- Under 10 yuan
- 10-20 yuan
- 20-50 yuan
- 50-100 yuan
- Over 100 yuan
"""
try:
distribution = service.get_price_distribution()
return distribution
except Exception as e:
logger.error(f"Failed to get price distribution: {e}", exc_info=True)
return PriceDistributionResponse(items=[], total_count=0)
@router.get(
"/overview",
response_model=MarketOverview,
responses={
200: {"description": "Market overview"},
500: {"description": "Server error", "model": ErrorResponse},
},
summary="Get market overview",
description="Get comprehensive market overview including indices, stats, and sentiment"
)
def get_market_overview(
service: MarketService = Depends(get_market_service)
) -> MarketOverview:
"""
Get market overview
Returns comprehensive market data including:
- Major indices
- Up/down statistics
- Sentiment indicators
"""
try:
indices = service.get_indices()
stats = service.get_updown_stats()
sentiment = service.get_sentiment_indicators()
return MarketOverview(
indices=indices,
updown_stats=stats,
sentiment=sentiment,
update_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
except Exception as e:
logger.error(f"Failed to get market overview: {e}", exc_info=True)
return MarketOverview(
indices=[],
updown_stats=UpDownStats(
up_count=0, down_count=0, flat_count=0,
limit_up_count=0, limit_down_count=0, total_count=0
),
sentiment=[],
update_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
@router.get(
"/sentiment",
response_model=List[SentimentIndicator],
responses={
200: {"description": "Sentiment indicators"},
500: {"description": "Server error", "model": ErrorResponse},
},
summary="Get sentiment indicators",
description="Get market sentiment indicators"
)
def get_sentiment(
service: MarketService = Depends(get_market_service)
) -> List[SentimentIndicator]:
"""
Get sentiment indicators
Returns various sentiment data including:
- Fear & Greed Index
- Market breadth
- Volatility index
"""
try:
sentiment = service.get_sentiment_indicators()
return sentiment
except Exception as e:
logger.error(f"Failed to get sentiment: {e}", exc_info=True)
return []

@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
"""
===================================
Sector data endpoints
===================================
Responsibilities:
1. Provide sector momentum data
2. Provide sector ranking
"""
import logging
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Query, Depends
from api.v1.schemas.market import (
SectorMomentum,
SectorListResponse,
KLineChartResponse,
)
from api.v1.schemas.common import ErrorResponse
from src.services.market_service import MarketService
logger = logging.getLogger(__name__)
router = APIRouter()
def get_market_service() -> MarketService:
"""Dependency injection for MarketService"""
return MarketService()
@router.get(
"",
response_model=SectorListResponse,
responses={
200: {"description": "Sector momentum list"},
500: {"description": "Server error", "model": ErrorResponse},
},
summary="Get sector momentum list",
description="Get sector momentum ranking with sorting options"
)
def get_sectors(
sort: str = Query("momentumValue", description="Sort field (momentumValue, momentumScore, changePercent, rankChange)"),
order: str = Query("desc", description="Sort order (asc, desc)"),
limit: int = Query(20, ge=1, le=100, description="Limit count"),
period: int = Query(5, ge=1, le=60, description="Period for calculating momentum (days)"),
service: MarketService = Depends(get_market_service)
) -> SectorListResponse:
"""
Get sector momentum list
Returns sector momentum data sorted by specified field
"""
try:
sectors = service.get_sector_momentum(sort=sort, order=order, limit=limit, period=period)
return SectorListResponse(
items=sectors,
total=len(sectors)
)
except Exception as e:
logger.error(f"Failed to get sector momentum: {e}", exc_info=True)
return SectorListResponse(items=[], total=0)
@router.get(
"/{sector_code}/kline",
response_model=KLineChartResponse,
responses={
200: {"description": "Sector K-line chart data"},
404: {"description": "Sector not found", "model": ErrorResponse},
500: {"description": "Server error", "model": ErrorResponse},
},
summary="Get sector K-line chart",
description="Get sector K-line chart data for ECharts"
)
def get_sector_kline(
sector_code: str,
days: int = Query(60, ge=1, le=365, description="Number of days"),
service: MarketService = Depends(get_market_service)
) -> KLineChartResponse:
"""
Get sector K-line chart data
Returns K-line data formatted for ECharts
"""
try:
kline = service.get_sector_kline(sector_code, days=days)
return kline
except Exception as e:
logger.error(f"Failed to get sector kline: {e}", exc_info=True)
return KLineChartResponse(
categoryData=[],
values=[],
volumes=[],
stock_name=None
)

@ -1,61 +1,163 @@
# -*- coding: utf-8 -*-
"""
===================================
股票数据接口
Stock data endpoints
===================================
职责
1. 提供 GET /api/v1/stocks/{code}/quote 实时行情接口
2. 提供 GET /api/v1/stocks/{code}/history 历史行情接口
Responsibilities:
1. Provide stock quote endpoint
2. Provide stock history endpoint
3. Provide stock momentum recommendations
4. Provide new high/low stocks
5. Provide stock K-line chart data
"""
import logging
from typing import List
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, HTTPException, Query, Depends
from api.v1.schemas.stocks import (
StockQuote,
StockHistoryResponse,
KLineData,
)
from api.v1.schemas.market import (
StockMomentum,
StockMomentumResponse,
NewHighLowStock,
KLineChartResponse,
)
from api.v1.schemas.common import ErrorResponse
from src.services.stock_service import StockService
from src.services.market_service import MarketService
logger = logging.getLogger(__name__)
router = APIRouter()
def get_stock_service() -> StockService:
"""Dependency injection for StockService"""
return StockService()
def get_market_service() -> MarketService:
"""Dependency injection for MarketService"""
return MarketService()
@router.get(
"/momentum-recommendation",
response_model=StockMomentumResponse,
responses={
200: {"description": "Stock momentum recommendations"},
500: {"description": "Server error", "model": ErrorResponse},
},
summary="Get stock momentum recommendations",
description="Get stock momentum recommendations based on technical indicators"
)
def get_momentum_recommendation(
limit: int = Query(15, ge=1, le=50, description="Limit count"),
service: MarketService = Depends(get_market_service)
) -> StockMomentumResponse:
"""
Get stock momentum recommendations
Returns stocks with strong momentum signals
"""
try:
stocks = service.get_stock_momentum_recommendation(limit=limit)
return StockMomentumResponse(
items=stocks,
total=len(stocks)
)
except Exception as e:
logger.error(f"Failed to get momentum recommendation: {e}", exc_info=True)
return StockMomentumResponse(items=[], total=0)
@router.get(
"/new-high",
response_model=List[NewHighLowStock],
responses={
200: {"description": "New high stocks"},
500: {"description": "Server error", "model": ErrorResponse},
},
summary="Get new high stocks",
description="Get stocks that reached new high in recent days"
)
def get_new_high(
days: int = Query(20, ge=1, le=60, description="Days to look back"),
limit: int = Query(20, ge=1, le=50, description="Limit count"),
service: MarketService = Depends(get_market_service)
) -> List[NewHighLowStock]:
"""
Get new high stocks
Returns stocks that reached new high within specified days
"""
try:
stocks = service.get_new_high_stocks(days=days, limit=limit)
return stocks
except Exception as e:
logger.error(f"Failed to get new high stocks: {e}", exc_info=True)
return []
@router.get(
"/new-low",
response_model=List[NewHighLowStock],
responses={
200: {"description": "New low stocks"},
500: {"description": "Server error", "model": ErrorResponse},
},
summary="Get new low stocks",
description="Get stocks that reached new low in recent days"
)
def get_new_low(
days: int = Query(20, ge=1, le=60, description="Days to look back"),
limit: int = Query(20, ge=1, le=50, description="Limit count"),
service: MarketService = Depends(get_market_service)
) -> List[NewHighLowStock]:
"""
Get new low stocks
Returns stocks that reached new low within specified days
"""
try:
stocks = service.get_new_low_stocks(days=days, limit=limit)
return stocks
except Exception as e:
logger.error(f"Failed to get new low stocks: {e}", exc_info=True)
return []
@router.get(
"/{stock_code}/quote",
response_model=StockQuote,
responses={
200: {"description": "行情数据"},
404: {"description": "股票不存在", "model": ErrorResponse},
500: {"description": "服务器错误", "model": ErrorResponse},
200: {"description": "Stock quote data"},
404: {"description": "Stock not found", "model": ErrorResponse},
500: {"description": "Server error", "model": ErrorResponse},
},
summary="获取股票实时行情",
description="获取指定股票的最新行情数据"
summary="Get stock quote",
description="Get real-time stock quote"
)
def get_stock_quote(stock_code: str) -> StockQuote:
def get_stock_quote(
stock_code: str,
service: StockService = Depends(get_stock_service)
) -> StockQuote:
"""
获取股票实时行情
获取指定股票的最新行情数据
Get stock real-time quote
Args:
stock_code: 股票代码 60051900700AAPL
stock_code: Stock code (e.g., 600519, 00700, AAPL)
Returns:
StockQuote: 实时行情数据
Raises:
HTTPException: 404 - 股票不存在
StockQuote: Real-time quote data
"""
try:
service = StockService()
# 使用 def 而非 async defFastAPI 自动在线程池中执行
result = service.get_realtime_quote(stock_code)
if result is None:
@ -63,7 +165,7 @@ def get_stock_quote(stock_code: str) -> StockQuote:
status_code=404,
detail={
"error": "not_found",
"message": f"未找到股票 {stock_code} 的行情数据"
"message": f"Stock {stock_code} not found"
}
)
@ -85,12 +187,12 @@ def get_stock_quote(stock_code: str) -> StockQuote:
except HTTPException:
raise
except Exception as e:
logger.error(f"获取实时行情失败: {e}", exc_info=True)
logger.error(f"Failed to get stock quote: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail={
"error": "internal_error",
"message": f"获取实时行情失败: {str(e)}"
"message": f"Failed to get stock quote: {str(e)}"
}
)
@ -99,42 +201,37 @@ def get_stock_quote(stock_code: str) -> StockQuote:
"/{stock_code}/history",
response_model=StockHistoryResponse,
responses={
200: {"description": "历史行情数据"},
422: {"description": "不支持的周期参数", "model": ErrorResponse},
500: {"description": "服务器错误", "model": ErrorResponse},
200: {"description": "Stock history data"},
422: {"description": "Unsupported period", "model": ErrorResponse},
500: {"description": "Server error", "model": ErrorResponse},
},
summary="获取股票历史行情",
description="获取指定股票的历史 K 线数据"
summary="Get stock history",
description="Get stock historical K-line data"
)
def get_stock_history(
stock_code: str,
period: str = Query("daily", description="K 线周期", pattern="^(daily|weekly|monthly)$"),
days: int = Query(30, ge=1, le=365, description="获取天数")
period: str = Query("daily", description="K-line period", pattern="^(daily|weekly|monthly)$"),
days: int = Query(30, ge=1, le=365, description="Number of days"),
service: StockService = Depends(get_stock_service)
) -> StockHistoryResponse:
"""
获取股票历史行情
获取指定股票的历史 K 线数据
Get stock historical data
Args:
stock_code: 股票代码
period: K 线周期 (daily/weekly/monthly)
days: 获取天数
stock_code: Stock code
period: K-line period (daily/weekly/monthly)
days: Number of days
Returns:
StockHistoryResponse: 历史行情数据
StockHistoryResponse: Historical data
"""
try:
service = StockService()
# 使用 def 而非 async defFastAPI 自动在线程池中执行
result = service.get_history_data(
stock_code=stock_code,
period=period,
days=days
)
# 转换为响应模型
data = [
KLineData(
date=item.get("date"),
@ -157,7 +254,6 @@ def get_stock_history(
)
except ValueError as e:
# period 参数不支持的错误(如 weekly/monthly
raise HTTPException(
status_code=422,
detail={
@ -166,11 +262,45 @@ def get_stock_history(
}
)
except Exception as e:
logger.error(f"获取历史行情失败: {e}", exc_info=True)
logger.error(f"Failed to get stock history: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail={
"error": "internal_error",
"message": f"获取历史行情失败: {str(e)}"
"message": f"Failed to get stock history: {str(e)}"
}
)
@router.get(
"/{stock_code}/kline",
response_model=KLineChartResponse,
responses={
200: {"description": "K-line chart data"},
404: {"description": "Stock not found", "model": ErrorResponse},
500: {"description": "Server error", "model": ErrorResponse},
},
summary="Get stock K-line chart",
description="Get stock K-line chart data formatted for ECharts"
)
def get_stock_kline(
stock_code: str,
days: int = Query(60, ge=1, le=365, description="Number of days"),
service: MarketService = Depends(get_market_service)
) -> KLineChartResponse:
"""
Get stock K-line chart data
Returns K-line data formatted for ECharts visualization
"""
try:
kline = service.get_stock_kline_chart(stock_code, days=days)
return kline
except Exception as e:
logger.error(f"Failed to get stock kline: {e}", exc_info=True)
return KLineChartResponse(
categoryData=[],
values=[],
volumes=[],
stock_name=None
)

@ -1,19 +1,18 @@
# -*- coding: utf-8 -*-
"""
===================================
API v1 路由聚合
API v1 Router Aggregation
===================================
职责
1. 聚合 v1 版本的所有 endpoint 路由
2. 统一添加 /api/v1 前缀
Responsibilities:
1. Aggregate all v1 endpoint routers
2. Add unified /api/v1 prefix
"""
from fastapi import APIRouter
from api.v1.endpoints import analysis, history, stocks, backtest, system_config
from api.v1.endpoints import analysis, history, stocks, backtest, system_config, market, sectors
# 创建 v1 版本主路由
router = APIRouter(prefix="/api/v1")
router.include_router(
@ -45,3 +44,15 @@ router.include_router(
prefix="/system",
tags=["SystemConfig"]
)
router.include_router(
market.router,
prefix="/market",
tags=["Market"]
)
router.include_router(
sectors.router,
prefix="/sectors",
tags=["Sectors"]
)

@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
"""
===================================
Market data schemas
===================================
"""
from typing import Optional, List
from pydantic import BaseModel, Field
class MarketIndex(BaseModel):
"""Market index data"""
code: str = Field(..., description="Index code")
name: str = Field(..., description="Index name")
price: float = Field(..., description="Current price")
change: Optional[float] = Field(None, description="Change amount")
change_percent: Optional[float] = Field(None, description="Change percent")
volume: Optional[float] = Field(None, description="Volume")
amount: Optional[float] = Field(None, description="Amount")
class UpDownStats(BaseModel):
"""Up/down statistics"""
up_count: int = Field(..., description="Number of rising stocks")
down_count: int = Field(..., description="Number of falling stocks")
flat_count: int = Field(..., description="Number of flat stocks")
limit_up_count: int = Field(..., description="Number of limit up stocks")
limit_down_count: int = Field(..., description="Number of limit down stocks")
total_count: int = Field(..., description="Total count")
class SectorMomentum(BaseModel):
"""Sector momentum data"""
code: str = Field(..., description="Sector code")
name: str = Field(..., description="Sector name")
momentum_score: float = Field(..., description="Momentum score")
change_percent: float = Field(..., description="Change percent")
turnover_rate: Optional[float] = Field(None, description="Turnover rate")
leading_stock: Optional[str] = Field(None, description="Leading stock code")
leading_stock_name: Optional[str] = Field(None, description="Leading stock name")
momentum_value: Optional[float] = Field(None, description="Momentum value (n²/N)")
momentum_value_change: Optional[float] = Field(None, description="Momentum value change from previous")
rank: Optional[int] = Field(None, description="Current rank")
rank_change: Optional[int] = Field(None, description="Rank change from previous")
class StockMomentum(BaseModel):
"""Stock momentum recommendation"""
code: str = Field(..., description="Stock code")
name: str = Field(..., description="Stock name")
momentum_score: float = Field(..., description="Momentum score")
change_percent: float = Field(..., description="Change percent")
sector: Optional[str] = Field(None, description="Belonging sector")
recommendation: Optional[str] = Field(None, description="Recommendation level")
class NewHighLowStock(BaseModel):
"""New high/low stock"""
code: str = Field(..., description="Stock code")
name: str = Field(..., description="Stock name")
price: float = Field(..., description="Current price")
change_percent: float = Field(..., description="Change percent")
days_to_high: Optional[int] = Field(None, description="Days to new high")
days_to_low: Optional[int] = Field(None, description="Days to new low")
high_date: Optional[str] = Field(None, description="High date")
low_date: Optional[str] = Field(None, description="Low date")
class PriceDistribution(BaseModel):
"""Price distribution statistics"""
range_label: str = Field(..., description="Price range label")
count: int = Field(..., description="Stock count in this range")
percent: float = Field(..., description="Percentage")
class SentimentIndicator(BaseModel):
"""Market sentiment indicator"""
name: str = Field(..., description="Indicator name")
value: float = Field(..., description="Current value")
change: Optional[float] = Field(None, description="Change from previous")
level: Optional[str] = Field(None, description="Sentiment level (high/medium/low)")
description: Optional[str] = Field(None, description="Description")
class MarketOverview(BaseModel):
"""Market overview response"""
indices: List[MarketIndex] = Field(default_factory=list, description="Major indices")
updown_stats: UpDownStats = Field(..., description="Up/down statistics")
sentiment: List[SentimentIndicator] = Field(default_factory=list, description="Sentiment indicators")
update_time: str = Field(..., description="Update time")
class SectorListResponse(BaseModel):
"""Sector list response"""
items: List[SectorMomentum] = Field(default_factory=list, description="Sector list")
total: int = Field(..., description="Total count")
class StockMomentumResponse(BaseModel):
"""Stock momentum response"""
items: List[StockMomentum] = Field(default_factory=list, description="Stock list")
total: int = Field(..., description="Total count")
class NewHighLowResponse(BaseModel):
"""New high/low response"""
new_high: List[NewHighLowStock] = Field(default_factory=list, description="New high stocks")
new_low: List[NewHighLowStock] = Field(default_factory=list, description="New low stocks")
high_count: int = Field(..., description="Total new high count")
low_count: int = Field(..., description="Total new low count")
class PriceDistributionResponse(BaseModel):
"""Price distribution response"""
items: List[PriceDistribution] = Field(default_factory=list, description="Distribution items")
total_count: int = Field(..., description="Total stock count")
class KLineChartResponse(BaseModel):
"""K-line chart data for ECharts"""
categoryData: List[str] = Field(default_factory=list, description="Date list")
values: List[List[float]] = Field(default_factory=list, description="K-line values [open, close, low, high, volume]")
volumes: List[List[float]] = Field(default_factory=list, description="Volume data [index, volume, change_sign]")
stock_name: Optional[str] = Field(None, description="Stock name")

@ -10,6 +10,7 @@
"dependencies": {
"axios": "^1.13.4",
"camelcase-keys": "^10.0.2",
"echarts": "^5.6.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
@ -2457,6 +2458,16 @@
"node": ">= 0.4"
}
},
"node_modules/echarts": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.286",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
@ -4106,6 +4117,12 @@
"typescript": ">=4.8.4"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -4364,6 +4381,15 @@
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zustand": {
"version": "5.0.11",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",

@ -12,6 +12,7 @@
"dependencies": {
"axios": "^1.13.4",
"camelcase-keys": "^10.0.2",
"echarts": "^5.6.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",

@ -1,31 +1,38 @@
import type React from 'react';
import {BrowserRouter as Router, Routes, Route, NavLink} from 'react-router-dom';
import { BrowserRouter as Router, Routes, Route, NavLink } from 'react-router-dom';
import HomePage from './pages/HomePage';
import TrendPage from './pages/TrendPage';
import BacktestPage from './pages/BacktestPage';
import SettingsPage from './pages/SettingsPage';
import NotFoundPage from './pages/NotFoundPage';
import './App.css';
// 侧边导航图标
const HomeIcon: React.FC<{ active?: boolean }> = ({active}) => (
const HomeIcon: React.FC<{ active?: boolean }> = ({ active }) => (
<svg className="w-6 h-6" fill={active ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
);
const BacktestIcon: React.FC<{ active?: boolean }> = ({active}) => (
const TrendIcon: React.FC<{ active?: boolean }> = ({ active }) => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={active ? 2 : 1.5}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
);
const SettingsIcon: React.FC<{ active?: boolean }> = ({active}) => (
const BacktestIcon: React.FC<{ active?: boolean }> = ({ active }) => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={active ? 2 : 1.5}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
);
const SettingsIcon: React.FC<{ active?: boolean }> = ({ active }) => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={active ? 2 : 1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={active ? 2 : 1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
@ -43,6 +50,12 @@ const NAV_ITEMS: DockItem[] = [
to: '/',
icon: HomeIcon,
},
{
key: 'trend',
label: '趋势',
to: '/trend',
icon: TrendIcon,
},
{
key: 'backtest',
label: '回测',
@ -57,19 +70,18 @@ const NAV_ITEMS: DockItem[] = [
},
];
// Dock 导航栏
const DockNav: React.FC = () => {
return (
<aside className="dock-nav" aria-label="主导航">
<aside className="dock-nav" aria-label="Main navigation">
<div className="dock-surface">
<NavLink to="/" className="dock-logo" title="首页" aria-label="首页">
<NavLink to="/" className="dock-logo" title="Home" aria-label="Home">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</NavLink>
<nav className="dock-items" aria-label="页面">
<nav className="dock-items" aria-label="Pages">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
return (
@ -79,15 +91,15 @@ const DockNav: React.FC = () => {
end={item.to === '/'}
title={item.label}
aria-label={item.label}
className={({isActive}) => `dock-item${isActive ? ' is-active' : ''}`}
className={({ isActive }) => `dock-item${isActive ? ' is-active' : ''}`}
>
{({isActive}) => <Icon active={isActive}/>}
{({ isActive }) => <Icon active={isActive} />}
</NavLink>
);
})}
</nav>
<div className="dock-footer"/>
<div className="dock-footer" />
</div>
</aside>
);
@ -97,16 +109,15 @@ const App: React.FC = () => {
return (
<Router>
<div className="flex min-h-screen bg-base">
{/* Dock 导航 */}
<DockNav/>
<DockNav />
{/* 主内容区 */}
<main className="flex-1 dock-safe-area">
<Routes>
<Route path="/" element={<HomePage/>}/>
<Route path="/backtest" element={<BacktestPage/>}/>
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="*" element={<NotFoundPage/>}/>
<Route path="/" element={<HomePage />} />
<Route path="/trend" element={<TrendPage />} />
<Route path="/backtest" element={<BacktestPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
</div>

@ -0,0 +1,80 @@
import apiClient from './index';
import type {
MarketIndex,
UpDownStats,
SectorMomentum,
StockMomentum,
NewHighLowStock,
PriceDistribution,
SentimentIndicator,
KLineChartResponse,
} from '../types/market';
export const marketApi = {
getIndices: async (): Promise<MarketIndex[]> => {
const response = await apiClient.get('/market/indices');
return response.data;
},
getUpDownStats: async (): Promise<UpDownStats> => {
const response = await apiClient.get('/market/updown-stats');
return response.data;
},
getPriceDistribution: async (): Promise<{ items: PriceDistribution[]; total_count: number }> => {
const response = await apiClient.get('/market/price-distribution');
return response.data;
},
getSentiment: async (): Promise<SentimentIndicator[]> => {
const response = await apiClient.get('/market/sentiment');
return response.data;
},
getSectorMomentum: async (
sort: string = 'momentumValue',
order: string = 'desc',
limit: number = 20,
period: number = 5
): Promise<{ items: SectorMomentum[]; total: number }> => {
const response = await apiClient.get('/sectors', {
params: { sort, order, limit, period },
});
return response.data;
},
getSectorKline: async (sectorCode: string, days: number = 60): Promise<KLineChartResponse> => {
const response = await apiClient.get(`/sectors/${sectorCode}/kline`, {
params: { days },
});
return response.data;
},
getStockMomentumRecommendation: async (limit: number = 15): Promise<{ items: StockMomentum[]; total: number }> => {
const response = await apiClient.get('/stocks/momentum-recommendation', {
params: { limit },
});
return response.data;
},
getNewHighStocks: async (days: number = 20, limit: number = 20): Promise<NewHighLowStock[]> => {
const response = await apiClient.get('/stocks/new-high', {
params: { days, limit },
});
return response.data;
},
getNewLowStocks: async (days: number = 20, limit: number = 20): Promise<NewHighLowStock[]> => {
const response = await apiClient.get('/stocks/new-low', {
params: { days, limit },
});
return response.data;
},
getStockKline: async (stockCode: string, days: number = 60): Promise<KLineChartResponse> => {
const response = await apiClient.get(`/stocks/${stockCode}/kline`, {
params: { days },
});
return response.data;
},
};

@ -0,0 +1,222 @@
import type React from 'react';
import { useEffect, useRef } from 'react';
import type { KLineChartResponse } from '../../types/market';
interface KLineChartModalProps {
title: string;
code: string;
type: 'stock' | 'sector';
data: KLineChartResponse | null;
isLoading: boolean;
onClose: () => void;
}
const KLineChartModal: React.FC<KLineChartModalProps> = ({
title,
code,
type,
data,
isLoading,
onClose,
}) => {
const chartRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!data || !chartRef.current || isLoading) return;
const renderChart = async () => {
try {
const echarts = await import('echarts');
const chart = echarts.init(chartRef.current!, 'dark');
const option = {
backgroundColor: 'transparent',
animation: false,
legend: {
data: [type === 'stock' ? 'K线' : '板块走势', '成交量'],
bottom: 10,
textStyle: { color: '#8b949e' },
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
backgroundColor: 'rgba(22, 27, 34, 0.9)',
borderColor: '#30363d',
textStyle: { color: '#c9d1d9' },
},
axisPointer: {
link: [{ xAxisIndex: 'all' }],
},
grid: [
{
left: '10%',
right: '8%',
top: '10%',
height: '50%',
},
{
left: '10%',
right: '8%',
top: '70%',
height: '20%',
},
],
xAxis: [
{
type: 'category',
data: data.categoryData,
boundaryGap: false,
axisLine: { lineStyle: { color: '#30363d' } },
axisTick: { show: false },
axisLabel: { color: '#8b949e', fontSize: 10 },
splitLine: { show: false },
min: 'dataMin',
max: 'dataMax',
},
{
type: 'category',
gridIndex: 1,
data: data.categoryData,
boundaryGap: false,
axisLine: { lineStyle: { color: '#30363d' } },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
min: 'dataMin',
max: 'dataMax',
},
],
yAxis: [
{
scale: true,
axisLine: { lineStyle: { color: '#30363d' } },
axisTick: { show: false },
axisLabel: { color: '#8b949e', fontSize: 10 },
splitLine: {
lineStyle: {
color: '#21262d',
},
},
},
{
scale: true,
gridIndex: 1,
splitNumber: 2,
axisLine: { lineStyle: { color: '#30363d' } },
axisTick: { show: false },
axisLabel: { color: '#8b949e', fontSize: 10 },
splitLine: {
lineStyle: {
color: '#21262d',
},
},
},
],
dataZoom: [
{
type: 'inside',
xAxisIndex: [0, 1],
start: 50,
end: 100,
},
{
show: true,
xAxisIndex: [0, 1],
type: 'slider',
bottom: 10,
start: 50,
end: 100,
borderColor: '#30363d',
backgroundColor: '#161b22',
fillerColor: 'rgba(48, 54, 61, 0.4)',
handleStyle: {
color: '#58a6ff',
borderColor: '#58a6ff',
},
textStyle: { color: '#8b949e' },
},
],
series: [
{
name: type === 'stock' ? 'K线' : '板块走势',
type: 'candlestick',
data: data.values,
itemStyle: {
color: '#238636',
color0: '#da3633',
borderColor: '#238636',
borderColor0: '#da3633',
},
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: data.volumes,
itemStyle: {
color: function (params: { value: number[] }) {
const changeSign = params.value[2];
return changeSign >= 0 ? '#238636' : '#da3633';
},
},
},
],
};
chart.setOption(option);
const handleResize = () => chart.resize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
chart.dispose();
};
} catch (err) {
console.error('Failed to render chart:', err);
}
};
renderChart();
}, [data, isLoading, type]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="relative w-full max-w-4xl mx-4 bg-base rounded-xl border border-white/10 shadow-2xl animate-fade-in">
<div className="flex items-center justify-between px-4 py-3 border-b border-white/5">
<div>
<h2 className="text-base font-semibold text-white">{title}</h2>
<p className="text-xs text-muted">{code} · {type === 'stock' ? '个股' : '板块'} K线</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-hover transition-colors"
aria-label="Close"
>
<svg className="w-5 h-5 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-4">
{isLoading ? (
<div className="flex flex-col items-center justify-center h-[400px]">
<div className="w-8 h-8 border-2 border-cyan/20 border-t-cyan rounded-full animate-spin" />
<p className="mt-3 text-secondary text-sm">K线...</p>
</div>
) : data && data.values.length > 0 ? (
<div ref={chartRef} className="w-full h-[400px]" />
) : (
<div className="flex flex-col items-center justify-center h-[400px]">
<p className="text-muted text-sm">K线</p>
</div>
)}
</div>
</div>
</div>
);
};
export default KLineChartModal;

@ -1,29 +1,37 @@
import type React from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import type { HistoryItem, AnalysisReport, TaskInfo } from '../types/analysis';
import type { MarketIndex, UpDownStats, SectorMomentum, StockMomentum, NewHighLowStock, SentimentIndicator } from '../types/market';
import { historyApi } from '../api/history';
import { analysisApi, DuplicateTaskError } from '../api/analysis';
import { marketApi } from '../api/market';
import { validateStockCode } from '../utils/validation';
import { getRecentStartDate, toDateInputValue } from '../utils/format';
import { useAnalysisStore } from '../stores/analysisStore';
import { ReportSummary } from '../components/report';
import { HistoryList } from '../components/history';
import { TaskPanel } from '../components/tasks';
import { Card, Badge } from '../components/common';
import { useTaskStream } from '../hooks';
/**
* -
* + +
*/
function formatPercent(value: number): string {
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(2)}%`;
}
function getChangeColor(value: number): string {
if (value > 0) return 'text-emerald-400';
if (value < 0) return 'text-red-400';
return 'text-secondary';
}
const HomePage: React.FC = () => {
const { setLoading, setError: setStoreError } = useAnalysisStore();
// 输入状态
const [stockCode, setStockCode] = useState('');
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [inputError, setInputError] = useState<string>();
// 历史列表状态
const [historyItems, setHistoryItems] = useState<HistoryItem[]>([]);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
@ -31,18 +39,24 @@ const HomePage: React.FC = () => {
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 20;
// 报告详情状态
const [selectedReport, setSelectedReport] = useState<AnalysisReport | null>(null);
const [isLoadingReport, setIsLoadingReport] = useState(false);
// 任务队列状态
const [activeTasks, setActiveTasks] = useState<TaskInfo[]>([]);
const [duplicateError, setDuplicateError] = useState<string | null>(null);
// 用于跟踪当前分析请求,避免竞态条件
const analysisRequestIdRef = useRef<number>(0);
// 更新任务列表中的任务
const [indices, setIndices] = useState<MarketIndex[]>([]);
const [updownStats, setUpdownStats] = useState<UpDownStats | null>(null);
const [sectors, setSectors] = useState<SectorMomentum[]>([]);
const [stockRecommendations, setStockRecommendations] = useState<StockMomentum[]>([]);
const [newHigh, setNewHigh] = useState<NewHighLowStock[]>([]);
const [newLow, setNewLow] = useState<NewHighLowStock[]>([]);
const [sentiment, setSentiment] = useState<SentimentIndicator[]>([]);
const [isLoadingMarket, setIsLoadingMarket] = useState(true);
const updateTask = useCallback((updatedTask: TaskInfo) => {
setActiveTasks((prev) => {
const index = prev.findIndex((t) => t.taskId === updatedTask.taskId);
@ -55,41 +69,60 @@ const HomePage: React.FC = () => {
});
}, []);
// 移除已完成/失败的任务
const removeTask = useCallback((taskId: string) => {
setActiveTasks((prev) => prev.filter((t) => t.taskId !== taskId));
}, []);
// SSE 任务流
useTaskStream({
onTaskCreated: (task) => {
setActiveTasks((prev) => {
// 避免重复添加
if (prev.some((t) => t.taskId === task.taskId)) return prev;
return [...prev, task];
});
},
onTaskStarted: updateTask,
onTaskCompleted: (task) => {
// 刷新历史列表
fetchHistory();
// 延迟移除任务,让用户看到完成状态
setTimeout(() => removeTask(task.taskId), 2000);
},
onTaskFailed: (task) => {
updateTask(task);
// 显示错误提示
setStoreError(task.error || '分析失败');
// 延迟移除任务
setStoreError(task.error || 'Analysis failed');
setTimeout(() => removeTask(task.taskId), 5000);
},
onError: () => {
console.warn('SSE 连接断开,正在重连...');
console.warn('SSE connection lost, reconnecting...');
},
enabled: true,
});
// 加载历史列表
const fetchMarketData = useCallback(async () => {
setIsLoadingMarket(true);
try {
const [indicesRes, statsRes, sectorsRes, stocksRes, highRes, lowRes, sentimentRes] = await Promise.all([
marketApi.getIndices(),
marketApi.getUpDownStats(),
marketApi.getSectorMomentum('momentumScore', 'desc', 5),
marketApi.getStockMomentumRecommendation(15),
marketApi.getNewHighStocks(20, 20),
marketApi.getNewLowStocks(20, 20),
marketApi.getSentiment(),
]);
setIndices(Array.isArray(indicesRes) ? indicesRes : []);
setUpdownStats(statsRes || null);
setSectors(Array.isArray(sectorsRes?.items) ? sectorsRes.items : []);
setStockRecommendations(Array.isArray(stocksRes?.items) ? stocksRes.items : []);
setNewHigh(Array.isArray(highRes) ? highRes : []);
setNewLow(Array.isArray(lowRes) ? lowRes : []);
setSentiment(Array.isArray(sentimentRes) ? sentimentRes : []);
} catch (err) {
console.error('Failed to fetch market data:', err);
} finally {
setIsLoadingMarket(false);
}
}, []);
const fetchHistory = useCallback(async (autoSelectFirst = false, reset = true) => {
if (reset) {
setIsLoadingHistory(true);
@ -114,12 +147,10 @@ const HomePage: React.FC = () => {
setHistoryItems(prev => [...prev, ...response.items]);
}
// 判断是否还有更多数据
const totalLoaded = reset ? response.items.length : historyItems.length + response.items.length;
setHasMore(totalLoaded < response.total);
setCurrentPage(page);
// 如果需要自动选择第一条,且有数据,且当前没有选中报告
if (autoSelectFirst && response.items.length > 0 && !selectedReport) {
const firstItem = response.items[0];
setIsLoadingReport(true);
@ -140,23 +171,19 @@ const HomePage: React.FC = () => {
}
}, [selectedReport, currentPage, historyItems.length, pageSize]);
// 加载更多历史记录
const handleLoadMore = useCallback(() => {
if (!isLoadingMore && hasMore) {
fetchHistory(false, false);
}
}, [fetchHistory, isLoadingMore, hasMore]);
// 初始加载 - 自动选择第一条
useEffect(() => {
fetchMarketData();
fetchHistory(true);
}, []);
// 点击历史项加载报告
const handleHistoryClick = async (queryId: string) => {
// 取消当前分析请求的结果显示(通过递增 requestId
analysisRequestIdRef.current += 1;
setIsLoadingReport(true);
try {
const report = await historyApi.getDetail(queryId);
@ -168,7 +195,6 @@ const HomePage: React.FC = () => {
}
};
// 分析股票(异步模式)
const handleAnalyze = async () => {
const { valid, message, normalized } = validateStockCode(stockCode);
if (!valid) {
@ -182,31 +208,26 @@ const HomePage: React.FC = () => {
setLoading(true);
setStoreError(null);
// 记录当前请求的 ID
const currentRequestId = ++analysisRequestIdRef.current;
try {
// 使用异步模式提交分析
const response = await analysisApi.analyzeAsync({
stockCode: normalized,
reportType: 'detailed',
});
// 清空输入框
if (currentRequestId === analysisRequestIdRef.current) {
setStockCode('');
}
// 任务已提交SSE 会推送更新
console.log('Task submitted:', response.taskId);
} catch (err) {
console.error('Analysis failed:', err);
if (currentRequestId === analysisRequestIdRef.current) {
if (err instanceof DuplicateTaskError) {
// 显示重复任务错误
setDuplicateError(`股票 ${err.stockCode} 正在分析中,请等待完成`);
setDuplicateError(`Stock ${err.stockCode} is being analyzed, please wait`);
} else {
setStoreError(err instanceof Error ? err.message : '分析失败');
setStoreError(err instanceof Error ? err.message : 'Analysis failed');
}
}
} finally {
@ -215,7 +236,6 @@ const HomePage: React.FC = () => {
}
};
// 回车提交
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && stockCode && !isAnalyzing) {
handleAnalyze();
@ -224,7 +244,6 @@ const HomePage: React.FC = () => {
return (
<div className="min-h-screen flex flex-col">
{/* 顶部输入栏 */}
<header className="flex-shrink-0 px-4 py-3 border-b border-white/5">
<div className="flex items-center gap-2 max-w-2xl">
<div className="flex-1 relative">
@ -236,7 +255,7 @@ const HomePage: React.FC = () => {
setInputError(undefined);
}}
onKeyDown={handleKeyDown}
placeholder="输入股票代码,如 600519、00700、AAPL"
placeholder="Enter stock code, e.g., 600519, 00700, AAPL"
disabled={isAnalyzing}
className={`input-terminal w-full ${inputError ? 'border-danger/50' : ''}`}
/>
@ -259,23 +278,174 @@ const HomePage: React.FC = () => {
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Analyzing
</>
) : (
'分析'
'Analyze'
)}
</button>
</div>
</header>
{/* 主内容区 */}
<main className="flex-1 flex overflow-hidden p-3 gap-3">
{/* 左侧:任务面板 + 历史列表 */}
<div className="flex flex-col gap-3 w-64 flex-shrink-0 overflow-hidden">
{/* 任务面板 */}
<TaskPanel tasks={activeTasks} />
<main className="flex-1 overflow-y-auto p-3">
{isLoadingMarket ? (
<div className="flex flex-col items-center justify-center h-64">
<div className="w-10 h-10 border-3 border-cyan/20 border-t-cyan rounded-full animate-spin" />
<p className="mt-3 text-secondary text-sm">Loading market data...</p>
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{indices.map((idx) => (
<Card key={idx.code} variant="gradient" padding="sm" className="animate-fade-in">
<div className="text-xs text-muted">{idx.name}</div>
<div className="text-lg font-mono font-semibold text-white mt-1">
{idx.price.toFixed(2)}
</div>
<div className={`text-xs font-mono mt-1 ${getChangeColor(idx.change_percent || 0)}`}>
{formatPercent(idx.change_percent || 0)}
</div>
</Card>
))}
</div>
{updownStats && (
<Card variant="gradient" padding="md" className="animate-fade-in">
<div className="mb-3">
<span className="label-uppercase">Market Breadth</span>
</div>
<div className="grid grid-cols-5 gap-4">
<div className="text-center">
<div className="text-xl font-mono text-emerald-400">{updownStats.up_count}</div>
<div className="text-xs text-muted mt-1">Up</div>
</div>
<div className="text-center">
<div className="text-xl font-mono text-red-400">{updownStats.down_count}</div>
<div className="text-xs text-muted mt-1">Down</div>
</div>
<div className="text-center">
<div className="text-xl font-mono text-secondary">{updownStats.flat_count}</div>
<div className="text-xs text-muted mt-1">Flat</div>
</div>
<div className="text-center">
<div className="text-xl font-mono text-emerald-400 glow">{updownStats.limit_up_count}</div>
<div className="text-xs text-muted mt-1">Limit Up</div>
</div>
<div className="text-center">
<div className="text-xl font-mono text-red-400">{updownStats.limit_down_count}</div>
<div className="text-xs text-muted mt-1">Limit Down</div>
</div>
</div>
</Card>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card variant="gradient" padding="md" className="animate-fade-in">
<div className="mb-3">
<span className="label-uppercase">Sector Momentum TOP5</span>
</div>
<div className="space-y-2">
{sectors.map((sector, idx) => (
<div key={sector.code} className="flex items-center justify-between py-2 px-3 rounded-lg bg-elevated/50">
<div className="flex items-center gap-2">
<span className="text-xs text-muted font-mono">{idx + 1}</span>
<span className="text-sm text-white">{sector.name}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-mono text-cyan">{sector.momentum_score.toFixed(1)}</span>
<span className={`text-xs font-mono ${getChangeColor(sector.change_percent)}`}>
{formatPercent(sector.change_percent)}
</span>
</div>
</div>
))}
</div>
</Card>
<Card variant="gradient" padding="md" className="animate-fade-in">
<div className="mb-3">
<span className="label-uppercase">Stock Recommendations</span>
</div>
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{stockRecommendations.slice(0, 10).map((stock, idx) => (
<div key={stock.code} className="flex items-center justify-between py-2 px-3 rounded-lg bg-elevated/50">
<div className="flex items-center gap-2">
<span className="text-xs text-muted font-mono">{idx + 1}</span>
<div>
<span className="text-sm text-white">{stock.name}</span>
<span className="text-xs text-muted ml-1">{stock.code}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={stock.recommendation === 'buy' ? 'success' : stock.recommendation === 'watch' ? 'warning' : 'default'}>
{stock.recommendation?.toUpperCase() || 'HOLD'}
</Badge>
<span className={`text-xs font-mono ${getChangeColor(stock.change_percent)}`}>
{formatPercent(stock.change_percent)}
</span>
</div>
</div>
))}
</div>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card variant="gradient" padding="md" className="animate-fade-in">
<div className="mb-3">
<span className="label-uppercase">New High (20d)</span>
</div>
<div className="space-y-2 max-h-[150px] overflow-y-auto">
{newHigh.slice(0, 8).map((stock) => (
<div key={stock.code} className="flex items-center justify-between py-1.5 px-3 rounded-lg bg-elevated/50">
<div>
<span className="text-sm text-white">{stock.name}</span>
<span className="text-xs text-muted ml-1">{stock.code}</span>
</div>
<span className="text-xs font-mono text-emerald-400">{formatPercent(stock.change_percent)}</span>
</div>
))}
</div>
</Card>
{/* 历史列表 */}
<Card variant="gradient" padding="md" className="animate-fade-in">
<div className="mb-3">
<span className="label-uppercase">New Low (20d)</span>
</div>
<div className="space-y-2 max-h-[150px] overflow-y-auto">
{newLow.slice(0, 8).map((stock) => (
<div key={stock.code} className="flex items-center justify-between py-1.5 px-3 rounded-lg bg-elevated/50">
<div>
<span className="text-sm text-white">{stock.name}</span>
<span className="text-xs text-muted ml-1">{stock.code}</span>
</div>
<span className="text-xs font-mono text-red-400">{formatPercent(stock.change_percent)}</span>
</div>
))}
</div>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{sentiment.map((ind) => (
<Card key={ind.name} variant="gradient" padding="sm" className="animate-fade-in">
<div className="text-xs text-muted">{ind.name}</div>
<div className="text-lg font-mono font-semibold text-white mt-1">
{ind.value.toFixed(ind.value < 10 ? 2 : 0)}
</div>
{ind.change !== undefined && (
<div className={`text-xs font-mono mt-1 ${getChangeColor(ind.change)}`}>
{formatPercent(ind.change)}
</div>
)}
<div className="text-xs text-muted mt-1">{ind.description}</div>
</Card>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<TaskPanel tasks={activeTasks} />
<HistoryList
items={historyItems}
isLoading={isLoadingHistory}
@ -284,20 +454,18 @@ const HomePage: React.FC = () => {
selectedQueryId={selectedReport?.meta.queryId}
onItemClick={handleHistoryClick}
onLoadMore={handleLoadMore}
className="max-h-[62vh] overflow-hidden"
className="max-h-[40vh] overflow-hidden"
/>
</div>
{/* 右侧报告详情 */}
<section className="flex-1 overflow-y-auto pl-1">
<section className="flex-1 overflow-y-auto">
{isLoadingReport ? (
<div className="flex flex-col items-center justify-center h-full">
<div className="w-10 h-10 border-3 border-cyan/20 border-t-cyan rounded-full animate-spin" />
<p className="mt-3 text-secondary text-sm">...</p>
<p className="mt-3 text-secondary text-sm">Loading report...</p>
</div>
) : selectedReport ? (
<div className="max-w-4xl">
{/* 报告内容 */}
<ReportSummary data={selectedReport} isHistory />
</div>
) : (
@ -307,13 +475,16 @@ const HomePage: React.FC = () => {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<h3 className="text-base font-medium text-white mb-1.5"></h3>
<h3 className="text-base font-medium text-white mb-1.5">Start Analysis</h3>
<p className="text-xs text-muted max-w-xs">
Enter stock code to analyze, or select from history
</p>
</div>
)}
</section>
</div>
</div>
)}
</main>
</div>
);

@ -0,0 +1,302 @@
import type React from 'react';
import { useState, useEffect, useCallback } from 'react';
import { Card, Badge } from '../components/common';
import { marketApi } from '../api/market';
import type {
SectorMomentum,
StockMomentum,
NewHighLowStock,
UpDownStats,
KLineChartResponse,
} from '../types/market';
import KLineChartModal from '../components/charts/KLineChartModal';
function formatPercent(value: number): string {
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(2)}%`;
}
function formatValue(value: number): string {
return value.toFixed(4);
}
function getChangeColor(value: number): string {
if (value > 0) return 'text-emerald-400';
if (value < 0) return 'text-red-400';
return 'text-secondary';
}
function getRankChangeColor(change: number): string {
if (change > 0) return 'text-emerald-400'; // 排名提升
if (change < 0) return 'text-red-400'; // 排名下降
return 'text-secondary';
}
function getRankChangeIcon(change: number): React.ReactNode {
if (change > 0) return '↑';
if (change < 0) return '↓';
return '→';
}
function recommendationBadge(rec?: string) {
if (!rec) return null;
switch (rec) {
case 'buy':
return <Badge variant="success" glow>BUY</Badge>;
case 'watch':
return <Badge variant="warning">WATCH</Badge>;
case 'hold':
return <Badge variant="default">HOLD</Badge>;
default:
return <Badge variant="default">{rec}</Badge>;
}
}
const TrendPage: React.FC = () => {
const [sectors, setSectors] = useState<SectorMomentum[]>([]);
const [stocks, setStocks] = useState<StockMomentum[]>([]);
const [newHigh, setNewHigh] = useState<NewHighLowStock[]>([]);
const [newLow, setNewLow] = useState<NewHighLowStock[]>([]);
const [updownStats, setUpdownStats] = useState<UpDownStats | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [chartModal, setChartModal] = useState<{
open: boolean;
type: 'stock' | 'sector';
code: string;
name: string;
} | null>(null);
const [chartData, setChartData] = useState<KLineChartResponse | null>(null);
const [isLoadingChart, setIsLoadingChart] = useState(false);
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
const [sectorsRes, stocksRes, highRes, lowRes, statsRes] = await Promise.all([
marketApi.getSectorMomentum('momentumValue', 'desc', 5),
marketApi.getStockMomentumRecommendation(15),
marketApi.getNewHighStocks(20, 20),
marketApi.getNewLowStocks(20, 20),
marketApi.getUpDownStats(),
]);
setSectors(Array.isArray(sectorsRes?.items) ? sectorsRes.items : []);
setStocks(Array.isArray(stocksRes?.items) ? stocksRes.items : []);
setNewHigh(Array.isArray(highRes) ? highRes : []);
setNewLow(Array.isArray(lowRes) ? lowRes : []);
setUpdownStats(statsRes || null);
} catch (err) {
console.error('Failed to fetch trend data:', err);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleItemClick = async (type: 'stock' | 'sector', code: string, name: string) => {
setChartModal({ open: true, type, code, name });
setIsLoadingChart(true);
setChartData(null);
try {
if (type === 'stock') {
const data = await marketApi.getStockKline(code, 60);
setChartData(data);
} else {
const data = await marketApi.getSectorKline(code, 60);
setChartData(data);
}
} catch (err) {
console.error('Failed to fetch kline data:', err);
} finally {
setIsLoadingChart(false);
}
};
const closeChartModal = () => {
setChartModal(null);
setChartData(null);
};
return (
<div className="min-h-screen flex flex-col">
<header className="flex-shrink-0 px-4 py-3 border-b border-white/5">
<h1 className="text-lg font-semibold text-white"></h1>
<p className="text-xs text-muted mt-1"></p>
</header>
<main className="flex-1 overflow-y-auto p-4">
{isLoading ? (
<div className="flex flex-col items-center justify-center h-64">
<div className="w-10 h-10 border-3 border-cyan/20 border-t-cyan rounded-full animate-spin" />
<p className="mt-3 text-secondary text-sm">...</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card variant="gradient" padding="md" className="animate-fade-in">
<div className="mb-3">
<span className="label-uppercase"> TOP5</span>
</div>
<div className="space-y-2">
{sectors.map((sector, idx) => (
<div
key={sector.code}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-elevated/50 hover:bg-hover cursor-pointer transition-colors"
onClick={() => handleItemClick('sector', sector.code, sector.name)}
>
<div className="flex items-center gap-2">
<span className="text-xs text-muted font-mono">{sector.rank || idx + 1}</span>
<span className="text-sm text-white">{sector.name}</span>
{sector.rank_change !== undefined && (
<span className={`text-xs font-mono ${getRankChangeColor(sector.rank_change)} ml-1`}>
{getRankChangeIcon(sector.rank_change)} {Math.abs(sector.rank_change)}
</span>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-mono text-cyan">{formatValue(sector.momentum_value || 0)}</span>
{sector.momentum_value_change !== undefined && (
<span className={`text-xs font-mono ${getChangeColor(sector.momentum_value_change)}`}>
{formatValue(sector.momentum_value_change)}
</span>
)}
<span className={`text-xs font-mono ${getChangeColor(sector.change_percent)}`}>
{formatPercent(sector.change_percent)}
</span>
</div>
</div>
))}
</div>
</Card>
<Card variant="gradient" padding="md" className="animate-fade-in">
<div className="mb-3">
<span className="label-uppercase"></span>
</div>
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{stocks.map((stock, idx) => (
<div
key={stock.code}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-elevated/50 hover:bg-hover cursor-pointer transition-colors"
onClick={() => handleItemClick('stock', stock.code, stock.name)}
>
<div className="flex items-center gap-2">
<span className="text-xs text-muted font-mono">{idx + 1}</span>
<div>
<span className="text-sm text-white">{stock.name}</span>
<span className="text-xs text-muted ml-1">{stock.code}</span>
</div>
</div>
<div className="flex items-center gap-2">
{recommendationBadge(stock.recommendation)}
<span className={`text-xs font-mono ${getChangeColor(stock.change_percent)}`}>
{formatPercent(stock.change_percent)}
</span>
</div>
</div>
))}
</div>
</Card>
<Card variant="gradient" padding="md" className="animate-fade-in">
<div className="mb-3">
<span className="label-uppercase"></span>
<span className="text-xs text-muted ml-2">(20)</span>
</div>
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{newHigh.slice(0, 10).map((stock) => (
<div
key={stock.code}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-elevated/50 hover:bg-hover cursor-pointer transition-colors"
onClick={() => handleItemClick('stock', stock.code, stock.name)}
>
<div>
<span className="text-sm text-white">{stock.name}</span>
<span className="text-xs text-muted ml-1">{stock.code}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-emerald-400">{stock.price.toFixed(2)}</span>
<span className="text-xs font-mono text-emerald-400">{formatPercent(stock.change_percent)}</span>
</div>
</div>
))}
</div>
</Card>
<Card variant="gradient" padding="md" className="animate-fade-in">
<div className="mb-3">
<span className="label-uppercase"></span>
<span className="text-xs text-muted ml-2">(20)</span>
</div>
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{newLow.slice(0, 10).map((stock) => (
<div
key={stock.code}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-elevated/50 hover:bg-hover cursor-pointer transition-colors"
onClick={() => handleItemClick('stock', stock.code, stock.name)}
>
<div>
<span className="text-sm text-white">{stock.name}</span>
<span className="text-xs text-muted ml-1">{stock.code}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-red-400">{stock.price.toFixed(2)}</span>
<span className="text-xs font-mono text-red-400">{formatPercent(stock.change_percent)}</span>
</div>
</div>
))}
</div>
</Card>
{updownStats && (
<Card variant="gradient" padding="md" className="animate-fade-in lg:col-span-2">
<div className="mb-3">
<span className="label-uppercase"></span>
</div>
<div className="grid grid-cols-5 gap-4">
<div className="text-center">
<div className="text-2xl font-mono text-emerald-400">{updownStats.up_count}</div>
<div className="text-xs text-muted mt-1"></div>
</div>
<div className="text-center">
<div className="text-2xl font-mono text-red-400">{updownStats.down_count}</div>
<div className="text-xs text-muted mt-1"></div>
</div>
<div className="text-center">
<div className="text-2xl font-mono text-secondary">{updownStats.flat_count}</div>
<div className="text-xs text-muted mt-1"></div>
</div>
<div className="text-center">
<div className="text-2xl font-mono text-emerald-400 glow">{updownStats.limit_up_count}</div>
<div className="text-xs text-muted mt-1"></div>
</div>
<div className="text-center">
<div className="text-2xl font-mono text-red-400">{updownStats.limit_down_count}</div>
<div className="text-xs text-muted mt-1"></div>
</div>
</div>
</Card>
)}
</div>
)}
</main>
{chartModal?.open && (
<KLineChartModal
title={chartModal.name}
code={chartModal.code}
type={chartModal.type}
data={chartData}
isLoading={isLoadingChart}
onClose={closeChartModal}
/>
)}
</div>
);
};
export default TrendPage;

@ -0,0 +1,73 @@
export interface MarketIndex {
code: string;
name: string;
price: number;
change?: number;
change_percent?: number;
volume?: number;
amount?: number;
}
export interface UpDownStats {
up_count: number;
down_count: number;
flat_count: number;
limit_up_count: number;
limit_down_count: number;
total_count: number;
}
export interface SectorMomentum {
code: string;
name: string;
momentum_score: number;
change_percent: number;
turnover_rate?: number;
leading_stock?: string;
leading_stock_name?: string;
momentum_value?: number;
momentum_value_change?: number;
rank?: number;
rank_change?: number;
}
export interface StockMomentum {
code: string;
name: string;
momentum_score: number;
change_percent: number;
sector?: string;
recommendation?: string;
}
export interface NewHighLowStock {
code: string;
name: string;
price: number;
change_percent: number;
days_to_high?: number;
days_to_low?: number;
high_date?: string;
low_date?: string;
}
export interface PriceDistribution {
range_label: string;
count: number;
percent: number;
}
export interface SentimentIndicator {
name: string;
value: number;
change?: number;
level?: string;
description?: string;
}
export interface KLineChartResponse {
categoryData: string[];
values: number[][];
volumes: number[][];
stock_name?: string;
}

@ -0,0 +1,443 @@
# -*- coding: utf-8 -*-
"""
===================================
AmazingData Custom Data Source Adapter
===================================
Adapter for AmazingData financial data platform API.
Provides stock K-line data, realtime quotes, and market statistics.
API Documentation: docs/customApi/API.md
"""
import logging
import time
import requests
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any, Tuple
import pandas as pd
from .base import BaseFetcher, DataFetchError, RateLimitError
logger = logging.getLogger(__name__)
class AmazingDataFetcher(BaseFetcher):
"""
AmazingData data source adapter
Features:
- JWT authentication with auto-refresh
- Stock K-line data (daily/minute)
- Realtime quotes
- Market statistics
- Sector rankings
Configuration (via .env):
- AMAZINGDATA_BASE_URL: API base URL (default: http://localhost:8000/api/v1)
- AMAZINGDATA_USERNAME: SDK username
- AMAZINGDATA_PASSWORD: SDK password
- AMAZINGDATA_HOST: SDK server host (default: 140.206.44.234)
- AMAZINGDATA_PORT: SDK server port (default: 8600)
"""
name: str = "AmazingDataFetcher"
priority: int = 5
DEFAULT_BASE_URL = "http://localhost:8000/api/v1"
DEFAULT_HOST = "140.206.44.234"
DEFAULT_PORT = 8600
TOKEN_EXPIRY_BUFFER = 300
def __init__(self):
"""Initialize AmazingData fetcher"""
self._base_url: Optional[str] = None
self._username: Optional[str] = None
self._password: Optional[str] = None
self._host: Optional[str] = None
self._port: Optional[int] = None
self._token: Optional[str] = None
self._token_expires_at: Optional[float] = None
self._initialized = False
self._available = False
self._init_config()
def _init_config(self) -> None:
"""Load configuration from environment"""
try:
from src.config import get_config
config = get_config()
self._base_url = getattr(config, 'amazingdata_base_url', None) or self.DEFAULT_BASE_URL
self._username = getattr(config, 'amazingdata_username', None)
self._password = getattr(config, 'amazingdata_password', None)
self._host = getattr(config, 'amazingdata_host', None) or self.DEFAULT_HOST
self._port = getattr(config, 'amazingdata_port', None) or self.DEFAULT_PORT
if self._username and self._password:
self._initialized = True
self._available = True
self.priority = 0
logger.info(f"[{self.name}] Initialized with base_url={self._base_url}")
else:
logger.debug(f"[{self.name}] Not configured (missing username/password)")
self._available = False
except Exception as e:
logger.warning(f"[{self.name}] Config load failed: {e}")
self._available = False
def _login(self) -> bool:
"""Authenticate and obtain JWT token"""
if not self._initialized:
return False
try:
response = requests.post(
f"{self._base_url}/auth/login",
json={"username": self._username, "password": self._password},
timeout=30
)
if response.status_code == 200:
data = response.json()
if data.get("code") == 200:
token_data = data.get("data", {})
self._token = token_data.get("access_token")
expires_in = token_data.get("expires_in", 86400)
self._token_expires_at = time.time() + expires_in - self.TOKEN_EXPIRY_BUFFER
logger.info(f"[{self.name}] Login successful, token valid for {expires_in}s")
return True
else:
logger.warning(f"[{self.name}] Login failed: {data.get('message')}")
return False
else:
logger.warning(f"[{self.name}] Login HTTP error: {response.status_code}")
return False
except Exception as e:
logger.error(f"[{self.name}] Login exception: {e}")
return False
def _get_headers(self) -> Dict[str, str]:
"""Get authorization headers"""
if not self._token or (self._token_expires_at and time.time() > self._token_expires_at):
if not self._login():
raise DataFetchError(f"[{self.name}] Authentication failed")
return {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json"
}
def _request(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
"""Make authenticated API request"""
headers = self._get_headers()
kwargs["headers"] = headers
kwargs["timeout"] = kwargs.get("timeout", 60)
url = f"{self._base_url}{path}"
try:
response = requests.request(method, url, **kwargs)
if response.status_code == 401:
logger.warning(f"[{self.name}] Token expired, re-authenticating...")
self._token = None
headers = self._get_headers()
kwargs["headers"] = headers
response = requests.request(method, url, **kwargs)
if response.status_code == 200:
return response.json()
else:
raise DataFetchError(f"[{self.name}] API error {response.status_code}: {response.text}")
except requests.Timeout:
raise DataFetchError(f"[{self.name}] Request timeout")
except requests.RequestException as e:
raise DataFetchError(f"[{self.name}] Request failed: {e}")
def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
"""
Fetch raw K-line data from AmazingData
Args:
stock_code: Stock code (e.g., '600519')
start_date: Start date (YYYY-MM-DD)
end_date: End date (YYYY-MM-DD)
Returns:
Raw DataFrame with K-line data
"""
if not self._available:
raise DataFetchError(f"[{self.name}] Not configured")
code_with_suffix = self._add_exchange_suffix(stock_code)
start_dt = start_date.replace("-", "")
end_dt = end_date.replace("-", "")
try:
response = self._request("GET", "/stock/kline", params={
"codes": code_with_suffix,
"start_date": start_dt,
"end_date": end_dt,
"period": "daily"
})
data = response.get("data", {})
kline_list = data.get(code_with_suffix, [])
if not kline_list:
raise DataFetchError(f"[{self.name}] No data for {stock_code}")
df = pd.DataFrame(kline_list)
return df
except DataFetchError:
raise
except Exception as e:
raise DataFetchError(f"[{self.name}] Fetch failed: {e}")
def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame:
"""
Normalize column names to standard format
Standard columns: date, open, high, low, close, volume, amount, pct_chg
"""
column_mapping = {
"trade_date": "date",
"open": "open",
"high": "high",
"low": "low",
"close": "close",
"volume": "volume",
"amount": "amount",
}
df = df.copy()
for old_col, new_col in column_mapping.items():
if old_col in df.columns and new_col not in df.columns:
df[new_col] = df[old_col]
if "pct_chg" not in df.columns:
if "close" in df.columns and len(df) > 1:
df["pct_chg"] = df["close"].pct_change() * 100
else:
df["pct_chg"] = 0.0
required_cols = ["date", "open", "high", "low", "close", "volume"]
missing_cols = [col for col in required_cols if col not in df.columns]
if missing_cols:
raise DataFetchError(f"[{self.name}] Missing columns: {missing_cols}")
return df
def _add_exchange_suffix(self, stock_code: str) -> str:
"""Add exchange suffix to stock code"""
code = stock_code.strip()
if "." in code:
return code
if code.startswith("6"):
return f"{code}.SH"
elif code.startswith(("0", "3")):
return f"{code}.SZ"
elif code.startswith("68"):
return f"{code}.SH"
else:
return f"{code}.SH"
def get_realtime_quote(self, stock_code: str):
"""
Get realtime quote for a stock
Args:
stock_code: Stock code
Returns:
UnifiedRealtimeQuote object or None
"""
if not self._available:
return None
try:
code_with_suffix = self._add_exchange_suffix(stock_code)
today = datetime.now().strftime("%Y%m%d")
week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y%m%d")
response = self._request("GET", "/stock/kline", params={
"codes": code_with_suffix,
"start_date": week_ago,
"end_date": today,
"period": "daily"
})
data = response.get("data", {})
kline_list = data.get(code_with_suffix, [])
if not kline_list:
return None
latest = kline_list[-1]
prev = kline_list[-2] if len(kline_list) > 1 else latest
from .realtime_types import UnifiedRealtimeQuote
close = float(latest.get("close", 0))
prev_close = float(prev.get("close", close))
change = close - prev_close
change_pct = (change / prev_close * 100) if prev_close else 0
quote = UnifiedRealtimeQuote(
code=stock_code,
name=None,
price=close,
open=float(latest.get("open", 0)),
high=float(latest.get("high", 0)),
low=float(latest.get("low", 0)),
prev_close=prev_close,
volume=float(latest.get("volume", 0) or 0),
amount=float(latest.get("amount", 0) or 0),
change_amount=change,
change_pct=change_pct,
)
return quote
except Exception as e:
logger.warning(f"[{self.name}] Realtime quote failed for {stock_code}: {e}")
return None
def get_main_indices(self) -> Optional[List[Dict[str, Any]]]:
"""Get major market indices realtime data"""
if not self._available:
return None
indices_codes = ["000001.SH", "399001.SZ", "399006.SZ", "000698.SH"]
try:
today = datetime.now().strftime("%Y%m%d")
week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y%m%d")
response = self._request("GET", "/stock/kline", params={
"codes": ",".join(indices_codes),
"start_date": week_ago,
"end_date": today,
"period": "daily"
})
data = response.get("data", {})
result = []
index_names = {
"000001.SH": "上证指数",
"399001.SZ": "深证成指",
"399006.SZ": "创业板指",
"000698.SH": "科创50",
}
for code in indices_codes:
kline_list = data.get(code, [])
if kline_list:
latest = kline_list[-1]
prev = kline_list[-2] if len(kline_list) > 1 else latest
close = float(latest.get("close", 0))
prev_close = float(prev.get("close", close))
change = close - prev_close
change_pct = (change / prev_close * 100) if prev_close else 0
result.append({
"code": code,
"name": index_names.get(code, code),
"current": close,
"change": change,
"change_pct": change_pct,
"volume": float(latest.get("volume", 0) or 0),
"amount": float(latest.get("amount", 0) or 0),
})
return result if result else None
except Exception as e:
logger.warning(f"[{self.name}] Get indices failed: {e}")
return None
def get_market_stats(self) -> Optional[Dict[str, Any]]:
"""Get market up/down statistics"""
return None
def get_sector_rankings(self, n: int = 5) -> Optional[Tuple[List[Dict], List[Dict]]]:
"""Get sector rankings"""
return None
def get_stock_name(self, stock_code: str) -> Optional[str]:
"""Get stock name from API"""
if not self._available:
return None
try:
code_with_suffix = self._add_exchange_suffix(stock_code)
response = self._request("GET", f"/base/codes/{code_with_suffix}/info")
data = response.get("data", {})
name = data.get("name")
if name:
logger.debug(f"[{self.name}] Got name for {stock_code}: {name}")
return name
return None
except Exception as e:
logger.debug(f"[{self.name}] Get stock name failed: {e}")
return None
def get_kline_chart_data(self, stock_code: str, days: int = 60) -> Optional[Dict[str, Any]]:
"""
Get K-line chart data formatted for ECharts
Args:
stock_code: Stock code
days: Number of days
Returns:
Dict with categoryData, values, volumes for ECharts
"""
if not self._available:
return None
try:
code_with_suffix = self._add_exchange_suffix(stock_code)
end_date = datetime.now().strftime("%Y%m%d")
start_date = (datetime.now() - timedelta(days=days * 2)).strftime("%Y%m%d")
response = self._request("GET", f"/stock/kline/{code_with_suffix}/chart", params={
"start_date": start_date,
"end_date": end_date,
"period": "daily"
})
data = response.get("data", {})
if data:
return {
"categoryData": data.get("categoryData", []),
"values": data.get("values", []),
"volumes": data.get("volumes", []),
"stock_name": data.get("stock_name"),
}
return None
except Exception as e:
logger.warning(f"[{self.name}] Get kline chart failed: {e}")
return None

@ -355,6 +355,7 @@ class DataFetcherManager:
from .pytdx_fetcher import PytdxFetcher
from .baostock_fetcher import BaostockFetcher
from .yfinance_fetcher import YfinanceFetcher
from .amazingdata_fetcher import AmazingDataFetcher
from src.config import get_config
config = get_config()
@ -366,9 +367,11 @@ class DataFetcherManager:
pytdx = PytdxFetcher() # 通达信数据源
baostock = BaostockFetcher()
yfinance = YfinanceFetcher()
amazingdata = AmazingDataFetcher() # 自定义数据源
# 初始化数据源列表
self._fetchers = [
amazingdata, # 自定义数据源优先级最高(如果配置了)
efinance,
akshare,
tushare,

@ -0,0 +1,370 @@
# 缓存更新策略
## 一、概述
缓存更新策略是保证缓存数据与数据源数据一致性的关键。本策略定义了股票分析系统中各类数据的缓存更新机制包括实时行情、K线数据、板块动量等数据的更新频率、方式和流程。
## 二、数据分类
根据数据的特性和更新频率,将数据分为以下几类:
| 数据类型 | 更新频率 | 数据源 | 缓存策略 |
|---------|---------|---------|---------|
| 实时行情 | 高频30秒/次) | 东方财富/新浪/腾讯 | 主动更新 + 惰性更新 |
| K线数据 | 低频(每日/次) | 东方财富/新浪/腾讯 | 定时更新 + 按需更新 |
| 板块动量 | 中频1小时/次) | 计算生成 | 定时更新 |
| 市场统计 | 中频1小时/次) | 计算生成 | 定时更新 |
| 股票基本信息 | 低频(每周/次) | 数据源 | 定时更新 |
| 板块信息 | 低频(每周/次) | 数据源 | 定时更新 |
## 三、缓存更新策略
### 1. 实时行情更新策略
#### 更新频率
- **全市场数据**每30秒更新一次
- **单只股票**:按需更新(缓存未命中时)
#### 更新流程
1. **定时任务**每30秒执行一次
2. **数据获取**调用数据源API获取全市场实时行情
3. **数据处理**
- 解析API返回数据
- 标准化数据格式
- 计算涨跌幅、换手率等指标
4. **缓存更新**
- 更新全市场缓存:`realtime:full`
- 批量更新单只股票缓存:`realtime:stock:{code}`
- 更新ETF缓存`realtime:etf:{code}`
- 记录更新批次:`realtime:batch`
5. **数据库更新**:异步批量写入数据库
#### 缓存失效处理
- **时间过期**设置30分钟过期时间
- **主动失效**:数据更新时覆盖旧数据
- **降级策略**:缓存过期后,请求时触发更新
### 2. K线数据更新策略
#### 更新频率
- **历史数据**:每日收盘后更新
- **最近数据**:按需更新(缓存未命中时)
#### 更新流程
1. **定时任务**:每日收盘后执行
2. **数据获取**
- 调用数据源API获取当日K线数据
- 对于新上市股票,获取完整历史数据
3. **数据处理**
- 标准化数据格式
- 计算技术指标
4. **缓存更新**
- 更新K线缓存`kline:{code}:{period}:{days}`
- 更新压缩K线缓存`kline:compressed:{code}:{period}:{days}`
5. **数据库更新**:批量写入数据库
#### 缓存失效处理
- **时间过期**设置1天过期时间
- **主动失效**:每日更新时覆盖旧数据
- **按需更新**:缓存未命中时触发更新
### 3. 板块动量更新策略
#### 更新频率
- **常规更新**:每小时更新一次
- **特殊情况**:市场剧烈波动时增加更新频率
#### 更新流程
1. **定时任务**:每小时执行一次
2. **数据获取**
- 从缓存或数据库获取实时行情
- 获取板块成分股信息
3. **计算过程**
- 按涨跌幅排序取前16%作为动量个股
- 按板块分组,计算每个板块的动量值
- 计算动量值变化、排名变化
4. **缓存更新**
- 更新板块动量缓存:`sector:momentum:{period}:{sort}:{order}:{limit}`
- 更新板块成分股缓存:`sector:stocks:{sector_code}`
5. **数据库更新**:写入板块动量表
#### 缓存失效处理
- **时间过期**设置1小时过期时间
- **主动失效**:更新时覆盖旧数据
- **降级策略**:缓存过期后,请求时触发更新
### 4. 市场统计更新策略
#### 更新频率
- **常规更新**:每小时更新一次
- **特殊情况**:市场开盘和收盘时增加更新频率
#### 更新流程
1. **定时任务**:每小时执行一次
2. **数据获取**
- 从缓存或数据库获取实时行情
- 统计市场涨跌分布
3. **计算过程**
- 统计上涨、下跌、平盘家数
- 统计涨停、跌停家数
- 计算两市成交额
4. **缓存更新**
- 更新市场统计缓存:`market:stats`
- 更新涨跌分布缓存:`market:distribution`
5. **数据库更新**:写入市场统计表
#### 缓存失效处理
- **时间过期**设置1小时过期时间
- **主动失效**:更新时覆盖旧数据
- **降级策略**:缓存过期后,请求时触发更新
### 5. 股票基本信息更新策略
#### 更新频率
- **常规更新**:每周更新一次
- **特殊情况**:新股上市、股票退市时即时更新
#### 更新流程
1. **定时任务**:每周执行一次
2. **数据获取**
- 调用数据源API获取股票基本信息
- 处理新股上市、股票退市等情况
3. **数据处理**
- 标准化数据格式
- 关联板块信息
4. **缓存更新**
- 更新股票基本信息缓存
- 更新板块成分股缓存
5. **数据库更新**:批量写入数据库
#### 缓存失效处理
- **时间过期**设置7天过期时间
- **主动失效**:更新时覆盖旧数据
- **按需更新**:缓存未命中时触发更新
### 6. 板块信息更新策略
#### 更新频率
- **常规更新**:每周更新一次
- **特殊情况**:板块调整时即时更新
#### 更新流程
1. **定时任务**:每周执行一次
2. **数据获取**
- 调用数据源API获取板块信息
- 处理板块调整情况
3. **数据处理**
- 标准化数据格式
- 计算板块成分股数量
4. **缓存更新**
- 更新板块基本信息缓存:`sector:info:{sector_code}`
- 更新板块成分股缓存:`sector:stocks:{sector_code}`
5. **数据库更新**:批量写入数据库
#### 缓存失效处理
- **时间过期**设置7天过期时间
- **主动失效**:更新时覆盖旧数据
- **按需更新**:缓存未命中时触发更新
## 四、缓存预热策略
### 1. 服务启动预热
- **预热数据**
- 热门股票实时行情
- 主要指数数据
- 最近的板块动量数据
- 市场统计数据
- **预热流程**
1. 服务启动时触发
2. 并行获取各类数据
3. 写入Redis缓存
4. 记录预热完成时间
### 2. 定时预热
- **预热频率**:每小时执行一次
- **预热数据**
- 即将到期的缓存
- 预测可能会被访问的数据
- 计算密集型数据
- **预热流程**
1. 定时任务触发
2. 分析缓存使用情况
3. 预测热门数据
4. 提前计算并缓存
### 3. 智能预热
- **基于访问模式**
- 分析历史访问记录
- 预测未来访问模式
- 提前缓存可能被访问的数据
- **基于市场事件**
- 市场开盘前预热
- 重大新闻发布后预热
- 财报季预热相关股票数据
## 五、缓存一致性保障
### 1. 数据一致性策略
- **最终一致性**
- 优先保证系统可用性
- 允许短暂的数据不一致
- 通过定期同步保证最终一致
- **强一致性**
- 对关键数据采用强一致性
- 使用分布式锁确保数据一致性
- 双写模式:同时更新数据库和缓存
### 2. 一致性实现
- **先更新数据库,后更新缓存**
1. 开始事务
2. 更新数据库
3. 提交事务
4. 更新缓存
5. 释放锁
- **缓存标记失效**
1. 更新数据库
2. 标记缓存失效
3. 下次读取时更新缓存
- **定期全量同步**
- 每日执行全量同步
- 验证缓存与数据库一致性
- 修复不一致数据
### 3. 并发控制
- **分布式锁**
- 使用Redis实现分布式锁
- 避免并发更新导致的数据不一致
- **乐观锁**
- 使用版本号或时间戳
- 检测并发冲突
- **队列处理**
- 将更新操作放入队列
- 串行处理确保顺序
## 六、缓存监控与告警
### 1. 监控指标
| 指标 | 描述 | 阈值 | 告警级别 |
|------|------|------|---------|
| 缓存命中率 | 缓存命中次数/总请求次数 | <80% | |
| 缓存更新延迟 | 数据更新到缓存的延迟 | >5秒 | 警告 |
| 缓存过期率 | 过期缓存占比 | >50% | 警告 |
| 缓存写入失败率 | 缓存写入失败次数/总写入次数 | >1% | 严重 |
| 缓存读取失败率 | 缓存读取失败次数/总读取次数 | >1% | 严重 |
### 2. 告警策略
- **缓存命中率低**
- 检查缓存策略是否合理
- 增加缓存预热
- 调整缓存过期时间
- **缓存更新延迟高**
- 检查数据源API响应时间
- 优化数据处理逻辑
- 增加更新线程数
- **缓存写入失败**
- 检查Redis连接状态
- 检查Redis内存使用情况
- 重启Redis服务
- **缓存读取失败**
- 检查Redis连接状态
- 检查网络连接
- 降级到数据库读取
### 3. 监控工具
- **Prometheus**:收集缓存相关指标
- **Grafana**:可视化缓存监控面板
- **Redis Exporter**导出Redis指标
- **Alertmanager**:处理告警
## 七、异常处理
### 1. 数据源异常
- **处理策略**
- 切换到备用数据源
- 使用缓存数据作为降级方案
- 记录异常并告警
- **恢复策略**
- 定期检查数据源状态
- 自动恢复到主数据源
- 验证数据一致性
### 2. Redis异常
- **处理策略**
- 降级到数据库读取
- 记录异常并告警
- 尝试重新连接
- **恢复策略**
- 监控Redis状态
- 自动恢复缓存
- 验证数据一致性
### 3. 数据库异常
- **处理策略**
- 继续使用缓存数据
- 记录异常并告警
- 尝试重新连接
- **恢复策略**
- 监控数据库状态
- 自动恢复数据同步
- 验证数据一致性
## 八、性能优化
### 1. 批量操作
- **批量读取**
- 使用MGET、HMGET等批量命令
- 减少网络往返次数
- **批量写入**
- 使用Pipeline批量执行命令
- 减少网络开销
### 2. 数据压缩
- **K线数据**
- 使用MsgPack或Protocol Buffers压缩
- 减少存储空间和网络传输
- **全量实时数据**
- 使用GZIP压缩
- 减少内存使用
### 3. 并发处理
- **多线程更新**
- 使用线程池并行处理数据
- 提高更新效率
- **异步更新**
- 使用消息队列异步处理
- 避免阻塞主线程
### 4. 缓存分层
- **热点数据**
- 存储在Redis中
- 高频访问
- **冷数据**
- 存储在数据库中
- 低频访问
- **预热数据**
- 提前计算并缓存
- 减少请求时计算开销
## 九、总结
缓存更新策略是保证股票分析系统性能和数据一致性的关键。通过合理的更新频率、流程和监控,可以确保缓存数据的及时性和准确性,同时提高系统的响应速度和可靠性。
本策略采用了多种技术手段,包括定时更新、按需更新、缓存预热、一致性保障等,以应对不同类型数据的更新需求。同时,通过监控和异常处理,确保系统在面对各种异常情况时能够稳定运行。
随着系统的发展和数据量的增长,缓存更新策略也需要不断优化和调整,以适应新的业务需求和技术挑战。

@ -0,0 +1,913 @@
# AmazingData 金融数据服务平台 - API 开发文档
## 一、API 概述
### 1.1 基本信息
- **Base URL**: `http://localhost:8000/api/v1`
- **认证方式**: JWT Bearer Token
- **数据格式**: JSON
- **编码**: UTF-8
### 1.2 认证说明
除登录接口外,所有接口都需要在请求头中携带 JWT Token
```
Authorization: Bearer {access_token}
```
---
## 二、认证接口
### 2.1 用户登录
**接口**: `POST /auth/login`
**功能**: 用户登录获取 JWT Token
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| username | string | 是 | 用户名 |
| password | string | 是 | 密码 |
**请求示例**:
```json
{
"username": "admin",
"password": "admin123"
}
```
**响应参数**:
| 参数名 | 类型 | 说明 |
|--------|------|------|
| code | int | 状态码 (200成功) |
| message | string | 消息 |
| data.access_token | string | JWT Token |
| data.token_type | string | Token类型 (bearer) |
| data.expires_in | int | 有效期(秒) |
**响应示例**:
```json
{
"code": 200,
"message": "登录成功",
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 86400
}
}
```
**调用示例**:
```python
import requests
response = requests.post(
'http://localhost:8000/api/v1/auth/login',
json={'username': 'admin', 'password': 'admin123'}
)
token = response.json()['data']['access_token']
```
---
### 2.2 获取当前用户信息
**接口**: `GET /auth/me`
**功能**: 获取当前登录用户信息
**请求参数**: 无
**响应参数**:
| 参数名 | 类型 | 说明 |
|--------|------|------|
| data.id | int | 用户ID |
| data.username | string | 用户名 |
| data.email | string | 邮箱 |
| data.role | string | 角色 |
| data.is_active | bool | 是否激活 |
**调用示例**:
```python
headers = {'Authorization': f'Bearer {token}'}
response = requests.get('http://localhost:8000/api/v1/auth/me', headers=headers)
user_info = response.json()['data']
```
---
## 三、SDK配置接口
### 3.1 获取SDK配置列表
**接口**: `GET /configs/sdk`
**功能**: 获取所有SDK配置列表
**请求参数**: 无
**响应参数**:
| 参数名 | 类型 | 说明 |
|--------|------|------|
| data.items | array | 配置列表 |
| data.total | int | 总数 |
**调用示例**:
```python
response = requests.get('http://localhost:8000/api/v1/configs/sdk', headers=headers)
configs = response.json()['data']['items']
```
---
### 3.2 创建SDK配置
**接口**: `POST /configs/sdk`
**功能**: 创建新的SDK配置
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| name | string | 是 | 配置名称 |
| username | string | 是 | SDK用户名 |
| password | string | 是 | SDK密码 |
| host | string | 是 | 服务器地址 |
| port | int | 否 | 端口号 (默认8600) |
| local_path | string | 否 | 本地路径 |
| is_default | bool | 否 | 是否默认配置 |
**请求示例**:
```json
{
"name": "生产环境SDK",
"username": "your_username",
"password": "your_password",
"host": "140.206.44.234",
"port": 8600,
"is_default": true
}
```
**调用示例**:
```python
response = requests.post(
'http://localhost:8000/api/v1/configs/sdk',
headers=headers,
json={
'name': '生产环境SDK',
'username': 'your_username',
'password': 'your_password',
'host': '140.206.44.234',
'port': 8600,
'is_default': True
}
)
```
---
### 3.3 测试SDK连接
**接口**: `POST /configs/sdk/{id}/test`
**功能**: 测试指定SDK配置的连接
**请求参数**: 无
**响应参数**:
| 参数名 | 类型 | 说明 |
|--------|------|------|
| data.success | bool | 是否成功 |
| data.message | string | 消息 |
| data.response_time | float | 响应时间(秒) |
**调用示例**:
```python
response = requests.post(
'http://localhost:8000/api/v1/configs/sdk/1/test',
headers=headers,
timeout=60
)
result = response.json()['data']
```
---
## 四、基础数据接口
### 4.1 获取代码列表
**接口**: `GET /base/codes`
**功能**: 获取指定类型的证券代码列表
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| security_type | string | 是 | 证券类型 |
**证券类型**:
- `EXTRA_STOCK_A`: 沪深A股
- `EXTRA_FUTURE`: 期货
- `EXTRA_ETF`: ETF
- `EXTRA_INDEX_A`: 指数
**响应参数**:
| 参数名 | 类型 | 说明 |
|--------|------|------|
| data | array | 代码列表 |
**调用示例**:
```python
response = requests.get(
'http://localhost:8000/api/v1/base/codes',
params={'security_type': 'EXTRA_STOCK_A'},
headers=headers
)
codes = response.json()['data']
```
---
### 4.2 获取交易日历
**接口**: `GET /base/calendar`
**功能**: 获取指定市场的交易日历
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| market | string | 是 | 市场代码 (SH, SZ, CFE) |
| start_date | string | 否 | 开始日期 (YYYYMMDD) |
| end_date | string | 否 | 结束日期 (YYYYMMDD) |
**响应参数**:
| 参数名 | 类型 | 说明 |
|--------|------|------|
| data | array | 交易日列表 |
**调用示例**:
```python
response = requests.get(
'http://localhost:8000/api/v1/base/calendar',
params={
'market': 'SH',
'start_date': '20240101',
'end_date': '20240131'
},
headers=headers
)
trading_days = response.json()['data']
```
---
### 4.3 获取证券信息
**接口**: `GET /base/codes/{code}/info`
**功能**: 获取指定代码的证券信息
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| code | string | 是 | 证券代码 (URL路径参数) |
**响应参数**:
| 参数名 | 类型 | 说明 |
|--------|------|------|
| data.code | string | 代码 |
| data.name | string | 名称 |
| data.market | string | 市场 |
| data.type | string | 类型 |
**调用示例**:
```python
response = requests.get(
'http://localhost:8000/api/v1/base/codes/600000.SH/info',
headers=headers
)
info = response.json()['data']
```
---
## 五、股票数据接口
### 5.1 获取股票K线数据
**接口**: `GET /stock/kline`
**功能**: 获取股票K线数据
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| codes | string | 是 | 代码列表 (逗号分隔) |
| start_date | string | 是 | 开始日期 (YYYYMMDD) |
| end_date | string | 是 | 结束日期 (YYYYMMDD) |
| period | string | 否 | 周期 (daily, min1, min5等) |
**响应参数**:
| 参数名 | 类型 | 说明 |
|--------|------|------|
| data.{code} | array | K线数据列表 |
| data.{code}[].trade_date | string | 交易日期 |
| data.{code}[].open | float | 开盘价 |
| data.{code}[].high | float | 最高价 |
| data.{code}[].low | float | 最低价 |
| data.{code}[].close | float | 收盘价 |
| data.{code}[].volume | int | 成交量 |
| data.{code}[].amount | float | 成交额 |
**调用示例**:
```python
response = requests.get(
'http://localhost:8000/api/v1/stock/kline',
params={
'codes': '600000.SH,000001.SZ',
'start_date': '20240101',
'end_date': '20240131',
'period': 'daily'
},
headers=headers
)
kline_data = response.json()['data']
```
---
### 5.2 获取股票K线图表数据
**接口**: `GET /stock/kline/{code}/chart`
**功能**: 获取股票K线图表数据 (ECharts格式)
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| code | string | 是 | 股票代码 (URL路径参数) |
| start_date | string | 是 | 开始日期 |
| end_date | string | 是 | 结束日期 |
| period | string | 否 | 周期 |
**响应参数**:
| 参数名 | 类型 | 说明 |
|--------|------|------|
| data.categoryData | array | 日期列表 |
| data.values | array | K线值 [open, close, low, high, volume] |
| data.volumes | array | 成交量数据 |
**调用示例**:
```python
response = requests.get(
'http://localhost:8000/api/v1/stock/kline/600000.SH/chart',
params={
'start_date': '20240101',
'end_date': '20240131',
'period': 'daily'
},
headers=headers
)
chart_data = response.json()['data']
```
---
### 5.3 批量获取股票K线
**接口**: `POST /stock/kline/batch`
**功能**: 批量获取多个股票的K线数据
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| codes | array | 是 | 代码列表 |
| start_date | string | 是 | 开始日期 |
| end_date | string | 是 | 结束日期 |
| period | string | 否 | 周期 |
**请求示例**:
```json
{
"codes": ["600000.SH", "000001.SZ", "000002.SZ"],
"start_date": "20240101",
"end_date": "20240131",
"period": "daily"
}
```
**调用示例**:
```python
response = requests.post(
'http://localhost:8000/api/v1/stock/kline/batch',
headers=headers,
json={
'codes': ['600000.SH', '000001.SZ'],
'start_date': '20240101',
'end_date': '20240131',
'period': 'daily'
}
)
```
---
## 六、期货数据接口
### 6.1 获取期货K线数据
**接口**: `GET /future/kline`
**功能**: 获取期货K线数据
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| codes | string | 是 | 代码列表 (逗号分隔) |
| start_date | string | 是 | 开始日期 |
| end_date | string | 是 | 结束日期 |
| period | string | 否 | 周期 |
**响应参数**:
| 参数名 | 类型 | 说明 |
|--------|------|------|
| data.{code} | array | K线数据 |
| data.{code}[].trade_date | string | 交易日期 |
| data.{code}[].open | float | 开盘价 |
| data.{code}[].high | float | 最高价 |
| data.{code}[].low | float | 最低价 |
| data.{code}[].close | float | 收盘价 |
| data.{code}[].volume | int | 成交量 |
| data.{code}[].settle | float | 结算价 |
| data.{code}[].open_interest | int | 持仓量 |
**调用示例**:
```python
response = requests.get(
'http://localhost:8000/api/v1/future/kline',
params={
'codes': 'IF2401.CFE',
'start_date': '20240101',
'end_date': '20240131',
'period': 'daily'
},
headers=headers
)
```
---
### 6.2 获取期货品种列表
**接口**: `GET /cache/future-varieties`
**功能**: 获取所有期货品种列表
**请求参数**: 无
**响应参数**:
| 参数名 | 类型 | 说明 |
|--------|------|------|
| data.varieties | array | 品种列表 |
**调用示例**:
```python
response = requests.get(
'http://localhost:8000/api/v1/cache/future-varieties',
headers=headers
)
varieties = response.json()['data']['varieties']
```
---
### 6.3 获取主力合约
**接口**: `GET /cache/main-contracts`
**功能**: 获取所有品种的主力合约
**请求参数**: 无
**响应参数**:
| 参数名 | 类型 | 说明 |
|--------|------|------|
| data.main_contracts | object | {品种: 主力合约} |
**调用示例**:
```python
response = requests.get(
'http://localhost:8000/api/v1/cache/main-contracts',
headers=headers
)
main_contracts = response.json()['data']['main_contracts']
```
---
## 七、缓存管理接口
### 7.1 一键检测所有数据
**接口**: `POST /cache/detect-all-missing`
**功能**: 检测所有数据的缺失情况
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| security_type | string | 是 | 证券类型 (stock, future) |
| period_type | string | 否 | 周期类型 (daily) |
| start_date | string | 是 | 开始日期 |
| end_date | string | 是 | 结束日期 |
| contract_type | string | 否 | 合约类型 (all, main) |
**请求示例**:
```json
{
"security_type": "stock",
"period_type": "daily",
"start_date": "20240101",
"end_date": "20240131",
"contract_type": "all"
}
```
**响应参数**:
| 参数名 | 类型 | 说明 |
|--------|------|------|
| data.task_id | int | 任务ID |
| data.status | string | 任务状态 |
| data.total_count | int | 检测总数 |
| data.complete_count | int | 完整数量 |
| data.missing_count | int | 缺失数量 |
| data.error_count | int | 错误数量 |
| data.daily_stats | object | 每日统计 |
| data.missing_codes | array | 缺失代码列表 |
**调用示例**:
```python
response = requests.post(
'http://localhost:8000/api/v1/cache/detect-all-missing',
headers=headers,
json={
'security_type': 'stock',
'period_type': 'daily',
'start_date': '20240101',
'end_date': '20240131',
'contract_type': 'all'
}
)
result = response.json()['data']
```
---
### 7.2 一键缓存所有数据
**接口**: `POST /cache/cache-all-missing`
**功能**: 缓存所有数据 (异步执行)
**请求参数**: 同检测接口
**响应参数**:
| 参数名 | 类型 | 说明 |
|--------|------|------|
| data.task_id | int | 任务ID |
| data.status | string | 任务状态 |
| data.total_count | int | 总数 |
| data.progress | float | 进度 |
**调用示例**:
```python
response = requests.post(
'http://localhost:8000/api/v1/cache/cache-all-missing',
headers=headers,
json={
'security_type': 'stock',
'period_type': 'daily',
'start_date': '20240101',
'end_date': '20240131'
}
)
task_id = response.json()['data']['task_id']
```
---
### 7.3 一键补齐缺失数据
**接口**: `POST /cache/fill-missing`
**功能**: 只补齐检测到的缺失数据 (异步执行)
**请求参数**: 同检测接口
**响应参数**:
| 参数名 | 类型 | 说明 |
|--------|------|------|
| data.task_id | int | 任务ID |
| data.missing_count | int | 缺失代码数量 |
| data.status | string | 任务状态 |
**调用示例**:
```python
response = requests.post(
'http://localhost:8000/api/v1/cache/fill-missing',
headers=headers,
json={
'security_type': 'stock',
'period_type': 'daily',
'start_date': '20240101',
'end_date': '20240131'
}
)
```
---
### 7.4 获取任务进度
**接口**: `GET /cache/tasks/{task_id}`
**功能**: 获取缓存任务进度
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| task_id | int | 是 | 任务ID (URL路径参数) |
**响应参数**:
| 参数名 | 类型 | 说明 |
|--------|------|------|
| data.task.id | int | 任务ID |
| data.task.status | string | 状态 |
| data.task.progress | float | 进度 |
| data.task.total_count | int | 总数 |
| data.task.success_count | int | 成功数 |
| data.task.error_count | int | 错误数 |
**调用示例**:
```python
response = requests.get(
f'http://localhost:8000/api/v1/cache/tasks/{task_id}',
headers=headers
)
task_status = response.json()['data']['task']
```
---
### 7.5 获取任务列表
**接口**: `GET /cache/tasks`
**功能**: 获取缓存任务列表
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| page | int | 否 | 页码 (默认1) |
| page_size | int | 否 | 每页数量 (默认20) |
**调用示例**:
```python
response = requests.get(
'http://localhost:8000/api/v1/cache/tasks',
params={'page': 1, 'page_size': 10},
headers=headers
)
tasks = response.json()['data']['items']
```
---
## 八、财务数据接口
### 8.1 获取资产负债表
**接口**: `GET /finance/balance-sheet`
**功能**: 获取资产负债表数据
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| codes | string | 是 | 代码列表 |
| start_date | string | 是 | 开始日期 |
| end_date | string | 是 | 结束日期 |
**调用示例**:
```python
response = requests.get(
'http://localhost:8000/api/v1/finance/balance-sheet',
params={
'codes': '600000.SH',
'start_date': '20230101',
'end_date': '20231231'
},
headers=headers
)
```
---
### 8.2 获取利润表
**接口**: `GET /finance/income`
**功能**: 获取利润表数据
**请求参数**: 同资产负债表
**调用示例**:
```python
response = requests.get(
'http://localhost:8000/api/v1/finance/income',
params={
'codes': '600000.SH',
'start_date': '20230101',
'end_date': '20231231'
},
headers=headers
)
```
---
### 8.3 获取现金流量表
**接口**: `GET /finance/cash-flow`
**功能**: 获取现金流量表数据
**请求参数**: 同资产负债表
**调用示例**:
```python
response = requests.get(
'http://localhost:8000/api/v1/finance/cash-flow',
params={
'codes': '600000.SH',
'start_date': '20230101',
'end_date': '20231231'
},
headers=headers
)
```
---
## 九、错误码说明
| 错误码 | 说明 |
|--------|------|
| 200 | 成功 |
| 400 | 请求参数错误 |
| 401 | 未授权 (Token无效或过期) |
| 403 | 禁止访问 |
| 404 | 资源不存在 |
| 500 | 服务器内部错误 |
---
## 十、最佳实践
### 10.1 Token 管理
```python
class AmazingDataClient:
def __init__(self, base_url, username, password):
self.base_url = base_url
self.token = None
self.login(username, password)
def login(self, username, password):
response = requests.post(
f'{self.base_url}/auth/login',
json={'username': username, 'password': password}
)
self.token = response.json()['data']['access_token']
def get_headers(self):
return {'Authorization': f'Bearer {self.token}'}
def request(self, method, path, **kwargs):
kwargs['headers'] = self.get_headers()
return requests.request(method, f'{self.base_url}{path}', **kwargs)
```
### 10.2 批量数据处理
```python
# 分批处理大量代码
codes = ['600000.SH', '000001.SZ', ...] # 大量代码
batch_size = 50
for i in range(0, len(codes), batch_size):
batch = codes[i:i+batch_size]
response = client.request('POST', '/stock/kline/batch', json={
'codes': batch,
'start_date': '20240101',
'end_date': '20240131'
})
```
### 10.3 异步任务轮询
```python
import time
def wait_for_task(client, task_id, timeout=300):
start_time = time.time()
while time.time() - start_time < timeout:
response = client.request('GET', f'/cache/tasks/{task_id}')
task = response.json()['data']['task']
if task['status'] == 'completed':
return task
elif task['status'] == 'failed':
raise Exception(task['error_message'])
time.sleep(2)
raise Exception('任务超时')
# 使用
response = client.request('POST', '/cache/fill-missing', json={...})
task_id = response.json()['data']['task_id']
result = wait_for_task(client, task_id)
```
---
## 十一、联系与支持
如有问题,请联系技术支持团队。

@ -0,0 +1,191 @@
# 数据库设计文档
## 一、设计概述
本数据库设计用于存储股票交易数据、市场统计信息和板块动量数据,支持股票分析系统的运行。设计采用关系型数据库,主要存储以下数据:
- 股票基本信息
- 股票实时行情
- K线历史数据
- 板块信息
- 板块动量数据
- 市场统计数据
## 二、数据模型设计
### 1. 股票基本信息表 (`stock_basic`)
| 字段名 | 数据类型 | 约束 | 描述 |
|-------|---------|------|------|
| `code` | `VARCHAR(10)` | `PRIMARY KEY` | 股票代码 |
| `name` | `VARCHAR(50)` | `NOT NULL` | 股票名称 |
| `sector_code` | `VARCHAR(10)` | `NULL` | 所属板块代码 |
| `list_date` | `DATE` | `NULL` | 上市日期 |
| `is_etf` | `BOOLEAN` | `DEFAULT FALSE` | 是否ETF |
| `market` | `VARCHAR(10)` | `NULL` | 市场(沪市/深市) |
| `updated_at` | `TIMESTAMP` | `DEFAULT CURRENT_TIMESTAMP` | 更新时间 |
### 2. 股票实时行情表 (`stock_quote`)
| 字段名 | 数据类型 | 约束 | 描述 |
|-------|---------|------|------|
| `code` | `VARCHAR(10)` | `PRIMARY KEY` | 股票代码 |
| `price` | `DECIMAL(10,2)` | `NULL` | 最新价 |
| `change_pct` | `DECIMAL(6,2)` | `NULL` | 涨跌幅(%) |
| `change_amount` | `DECIMAL(10,2)` | `NULL` | 涨跌额 |
| `volume` | `BIGINT` | `NULL` | 成交量 |
| `amount` | `DECIMAL(18,2)` | `NULL` | 成交额 |
| `turnover_rate` | `DECIMAL(8,4)` | `NULL` | 换手率(%) |
| `amplitude` | `DECIMAL(6,2)` | `NULL` | 振幅(%) |
| `high` | `DECIMAL(10,2)` | `NULL` | 最高价 |
| `low` | `DECIMAL(10,2)` | `NULL` | 最低价 |
| `open` | `DECIMAL(10,2)` | `NULL` | 开盘价 |
| `updated_at` | `TIMESTAMP` | `DEFAULT CURRENT_TIMESTAMP` | 更新时间 |
### 3. 股票K线数据表 (`stock_kline`)
| 字段名 | 数据类型 | 约束 | 描述 |
|-------|---------|------|------|
| `id` | `BIGINT` | `PRIMARY KEY AUTO_INCREMENT` | 自增主键 |
| `code` | `VARCHAR(10)` | `NOT NULL` | 股票代码 |
| `date` | `DATE` | `NOT NULL` | 日期 |
| `open` | `DECIMAL(10,2)` | `NULL` | 开盘价 |
| `high` | `DECIMAL(10,2)` | `NULL` | 最高价 |
| `low` | `DECIMAL(10,2)` | `NULL` | 最低价 |
| `close` | `DECIMAL(10,2)` | `NULL` | 收盘价 |
| `volume` | `BIGINT` | `NULL` | 成交量 |
| `amount` | `DECIMAL(18,2)` | `NULL` | 成交额 |
| `pct_chg` | `DECIMAL(6,2)` | `NULL` | 涨跌幅(%) |
| `INDEX` | | `(code, date)` | 复合索引 |
### 4. 板块信息表 (`sector_info`)
| 字段名 | 数据类型 | 约束 | 描述 |
|-------|---------|------|------|
| `code` | `VARCHAR(10)` | `PRIMARY KEY` | 板块代码 |
| `name` | `VARCHAR(50)` | `NOT NULL` | 板块名称 |
| `stock_count` | `INT` | `DEFAULT 0` | 成分股数量 |
| `updated_at` | `TIMESTAMP` | `DEFAULT CURRENT_TIMESTAMP` | 更新时间 |
### 5. 板块动量表 (`sector_momentum`)
| 字段名 | 数据类型 | 约束 | 描述 |
|-------|---------|------|------|
| `id` | `BIGINT` | `PRIMARY KEY AUTO_INCREMENT` | 自增主键 |
| `sector_code` | `VARCHAR(10)` | `NOT NULL` | 板块代码 |
| `date` | `DATE` | `NOT NULL` | 日期 |
| `momentum_value` | `DECIMAL(10,4)` | `NULL` | 动量值 |
| `n` | `INT` | `NULL` | 板块内动量个股数 |
| `N` | `INT` | `NULL` | 板块总个股数 |
| `rank` | `INT` | `NULL` | 动量排名 |
| `rank_change` | `INT` | `NULL` | 排名变化 |
| `updated_at` | `TIMESTAMP` | `DEFAULT CURRENT_TIMESTAMP` | 更新时间 |
| `INDEX` | | `(sector_code, date)` | 复合索引 |
### 6. 市场统计表 (`market_stats`)
| 字段名 | 数据类型 | 约束 | 描述 |
|-------|---------|------|------|
| `id` | `BIGINT` | `PRIMARY KEY AUTO_INCREMENT` | 自增主键 |
| `date` | `DATE` | `NOT NULL UNIQUE` | 日期 |
| `up_count` | `INT` | `DEFAULT 0` | 上涨家数 |
| `down_count` | `INT` | `DEFAULT 0` | 下跌家数 |
| `flat_count` | `INT` | `DEFAULT 0` | 平盘家数 |
| `limit_up_count` | `INT` | `DEFAULT 0` | 涨停家数 |
| `limit_down_count` | `INT` | `DEFAULT 0` | 跌停家数 |
| `total_amount` | `DECIMAL(20,2)` | `NULL` | 两市成交额 |
| `updated_at` | `TIMESTAMP` | `DEFAULT CURRENT_TIMESTAMP` | 更新时间 |
## 三、索引设计
### 1. 主键索引
- `stock_basic.code`
- `stock_quote.code`
- `sector_info.code`
- `stock_kline.id`
- `sector_momentum.id`
- `market_stats.id`
### 2. 复合索引
- `stock_kline(code, date)`:加速按股票代码和日期范围查询
- `sector_momentum(sector_code, date)`:加速按板块和日期查询
### 3. 唯一索引
- `market_stats.date`:确保每日只存储一条市场统计记录
## 四、数据同步策略
### 1. 实时数据
- **同步频率**30秒一次
- **同步方式**:批量更新
- **存储策略**:只保留最新数据
### 2. K线数据
- **同步频率**:每日收盘后
- **同步方式**:增量更新
- **存储策略**:保留完整历史数据
### 3. 板块数据
- **同步频率**:每日一次
- **同步方式**:全量更新
- **存储策略**:保留完整历史数据
### 4. 市场统计
- **同步频率**:每日收盘后
- **同步方式**:增量更新
- **存储策略**:保留完整历史数据
## 五、数据清理策略
### 1. 实时数据
- 无需清理,覆盖更新
### 2. K线数据
- 保留完整历史数据,不清理
### 3. 板块动量
- 保留最近一年数据
- 自动清理一年前的数据
### 4. 市场统计
- 保留完整历史数据,不清理
## 六、性能优化建议
1. **分区表**:对 `stock_kline` 表按日期分区,提高查询性能
2. **批量操作**:使用批量插入和更新,减少数据库连接次数
3. **索引优化**:根据查询模式优化索引
4. **缓存层**使用Redis缓存热点数据
5. **读写分离**:主库用于写入,从库用于读取
6. **连接池**:使用数据库连接池,减少连接建立开销
## 七、扩展性考虑
1. **分库分表**:数据量增长时,可考虑按股票代码分库分表
2. **时间序列数据库**K线数据可考虑使用时间序列数据库如InfluxDB
3. **数据仓库**:历史数据可迁移到数据仓库,用于分析
4. **API接口**提供标准化的API接口支持外部系统集成
## 八、安全考虑
1. **访问控制**:设置数据库用户权限,限制访问
2. **数据加密**:敏感数据加密存储
3. **备份策略**:定期备份数据库,确保数据安全
4. **审计日志**:记录数据库操作日志,便于追溯
## 九、技术选型
- **数据库**PostgreSQL 14+
- **连接池**PgBouncer
- **ORM**SQLAlchemy
- **缓存**Redis 7.0+
- **监控**Prometheus + Grafana
## 十、部署建议
1. **开发环境**:单节点数据库
2. **测试环境**:主从复制
3. **生产环境**
- 数据库:主从复制 + 自动故障转移
- Redis主从复制 + 哨兵模式
- 定期备份:每日全量备份,每小时增量备份

@ -0,0 +1,431 @@
# 实现步骤和迁移计划
## 一、概述
本文档详细说明股票分析系统引入数据库和Redis缓存的实现步骤和迁移计划包括基础设施搭建、代码修改、数据迁移、测试验证等环节确保系统平稳过渡到新的架构。
## 二、实施阶段
### 阶段一基础设施搭建1-2周
#### 1. 环境准备
- **硬件准备**
- 数据库服务器4核8G内存500G存储空间
- Redis服务器4核8G内存100G存储空间
- 网络配置:确保服务器间网络互通
- **软件安装**
- 数据库PostgreSQL 14+
- RedisRedis 7.0+
- 连接池PgBouncer
- 监控Prometheus + Grafana
#### 2. 配置管理
- **数据库配置**
- 创建数据库和用户
- 配置连接池
- 设置备份策略
- **Redis配置**
- 配置主从复制
- 设置内存限制
- 配置持久化
- **环境变量**
- 数据库连接信息
- Redis连接信息
- 缓存配置参数
### 阶段二数据模型实现2-3周
#### 1. 数据库表结构
- **执行创建脚本**
- 运行 `scripts/create_tables.sql`
- 创建所有数据表
- 建立索引
- **验证表结构**
- 检查表结构是否正确
- 验证索引是否创建
- 测试基本查询
#### 2. 数据访问层
- **实现数据访问类**
- 股票基本信息
- 实时行情
- K线数据
- 板块信息
- 市场统计
- **实现ORM映射**
- 使用SQLAlchemy
- 定义数据模型
- 实现CRUD操作
#### 3. 缓存工具类
- **实现Redis客户端**
- 连接管理
- 异常处理
- 连接池
- **实现缓存操作**
- 基础操作get/set/delete
- Hash操作
- Sorted Set操作
- List操作
- **实现缓存键生成器**
- 统一的缓存键格式
- 支持不同数据类型
### 阶段三业务逻辑集成3-4周
#### 1. 数据源适配
- **修改数据获取逻辑**
- 优先从缓存获取
- 缓存未命中从数据库获取
- 数据库未命中从API获取
- **实现数据同步**
- 实时行情同步
- K线数据同步
- 板块信息同步
#### 2. 服务层修改
- **修改MarketService**
- 集成Redis缓存
- 实现缓存读写逻辑
- 优化板块动量计算
- **修改数据服务**
- 集成数据库访问
- 实现数据持久化
- 优化数据查询
#### 3. API接口修改
- **修改API端点**
- 支持缓存控制参数
- 优化响应格式
- 增加缓存状态信息
- **实现缓存管理接口**
- 缓存清理
- 缓存预热
- 缓存状态查询
### 阶段四数据同步2-3周
#### 1. 历史数据导入
- **准备历史数据**
- 从数据源获取历史K线数据
- 整理股票基本信息
- 准备板块数据
- **批量导入**
- 使用批量插入
- 优化导入速度
- 验证数据完整性
#### 2. 实时数据同步
- **实现同步任务**
- 实时行情同步
- 市场统计同步
- 板块动量计算
- **设置定时任务**
- 实时行情30秒一次
- 板块动量1小时一次
- 市场统计1小时一次
#### 3. 数据验证
- **验证数据一致性**
- 缓存与数据库对比
- 数据库与数据源对比
- 历史数据与实时数据对比
- **性能测试**
- 数据同步速度
- 缓存命中率
- 系统响应时间
### 阶段五测试与优化2-3周
#### 1. 功能测试
- **单元测试**
- 数据访问层测试
- 缓存操作测试
- 服务层测试
- **集成测试**
- API接口测试
- 数据同步测试
- 缓存一致性测试
- **端到端测试**
- 完整业务流程测试
- 系统集成测试
- 用户场景测试
#### 2. 性能测试
- **负载测试**
- 模拟并发用户
- 测试系统极限
- 识别性能瓶颈
- **压力测试**
- 持续高负载
- 测试系统稳定性
- 验证系统恢复能力
- **基准测试**
- 与旧系统对比
- 验证性能提升
- 指导进一步优化
#### 3. 优化调整
- **根据测试结果优化**
- 数据库索引
- 缓存策略
- API性能
- **调整配置参数**
- 连接池大小
- 缓存过期时间
- 同步频率
### 阶段六上线部署1-2周
#### 1. 部署准备
- **环境准备**
- 生产环境配置
- 监控系统部署
- 告警策略配置
- **数据准备**
- 生产数据同步
- 缓存预热
- 系统初始化
#### 2. 灰度发布
- **分阶段部署**
- 先部署非核心服务
- 逐步切换流量
- 监控系统状态
- **回滚机制**
- 准备回滚方案
- 测试回滚流程
- 确保快速回滚能力
#### 3. 全量上线
- **切换流量**
- 逐步增加新系统流量
- 监控系统性能
- 处理异常情况
- **系统监控**
- 实时监控系统状态
- 及时处理告警
- 优化系统配置
## 三、迁移策略
### 1. 双写模式
- **实现方式**
- 同时写入旧缓存和新缓存
- 同时写入数据库
- 保持数据一致性
- **优点**
- 确保数据不丢失
- 便于回滚
- 降低迁移风险
### 2. 灰度切换
- **切换步骤**
- 10%流量:验证基本功能
- 30%流量:测试性能
- 50%流量:全面测试
- 100%流量:完全切换
- **监控指标**
- 响应时间
- 错误率
- 缓存命中率
- 系统负载
### 3. 回滚策略
- **触发条件**
- 系统响应时间显著增加
- 错误率超过阈值
- 数据不一致
- **回滚步骤**
- 停止新系统写入
- 切换流量回旧系统
- 验证旧系统运行状态
- 分析问题原因
### 4. 数据迁移
- **历史数据**
- 批量导入历史K线数据
- 导入股票基本信息
- 导入板块信息
- **实时数据**
- 并行同步实时行情
- 逐步切换到新系统
- 验证数据一致性
## 四、风险评估
### 1. 潜在风险
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|----------|
| 数据源API限流 | 数据获取失败 | 高 | 多数据源切换,增加缓存时间 |
| 数据库性能问题 | 查询缓慢 | 中 | 优化索引,使用连接池 |
| Redis内存不足 | 缓存失效 | 中 | 合理设置内存限制使用LRU策略 |
| 数据一致性问题 | 数据错误 | 中 | 双写模式,定期同步 |
| 系统负载过高 | 响应缓慢 | 中 | 水平扩展,优化代码 |
| 部署失败 | 系统不可用 | 低 | 灰度发布,准备回滚方案 |
### 2. 风险应对
- **数据源限流**
- 实现多数据源自动切换
- 增加缓存时间
- 批量获取数据
- **数据库性能**
- 优化索引
- 使用连接池
- 实现读写分离
- **Redis内存**
- 合理设置内存限制
- 使用LRU策略
- 数据压缩
- **数据一致性**
- 双写模式
- 定期同步
- 数据验证
- **系统负载**
- 水平扩展
- 优化代码
- 缓存预热
## 五、时间线
| 阶段 | 时间 | 主要任务 |
|------|------|----------|
| 阶段一:基础设施搭建 | 第1-2周 | 环境准备,软件安装,配置管理 |
| 阶段二:数据模型实现 | 第3-5周 | 数据库表结构,数据访问层,缓存工具类 |
| 阶段三:业务逻辑集成 | 第6-9周 | 数据源适配服务层修改API接口修改 |
| 阶段四:数据同步 | 第10-12周 | 历史数据导入,实时数据同步,数据验证 |
| 阶段五:测试与优化 | 第13-15周 | 功能测试,性能测试,优化调整 |
| 阶段六:上线部署 | 第16-17周 | 部署准备,灰度发布,全量上线 |
## 六、团队分工
### 1. 技术角色
| 角色 | 职责 |
|------|------|
| 系统架构师 | 整体架构设计,技术选型,方案评审 |
| 后端开发 | 数据访问层服务层API接口 |
| 前端开发 | 前端适配,用户界面优化 |
| 数据库工程师 | 数据库设计,性能优化,数据迁移 |
| 运维工程师 | 基础设施搭建,监控部署,系统维护 |
| 测试工程师 | 功能测试,性能测试,回归测试 |
### 2. 任务分配
- **系统架构师**
- 设计整体架构
- 制定技术方案
- 评审代码和设计
- **后端开发**
- 实现数据访问层
- 修改服务层逻辑
- 优化API接口
- **前端开发**
- 适配新的API接口
- 优化前端性能
- 测试用户界面
- **数据库工程师**
- 设计数据库表结构
- 优化数据库性能
- 实现数据迁移
- **运维工程师**
- 搭建基础设施
- 配置监控系统
- 部署和维护系统
- **测试工程师**
- 编写测试用例
- 执行测试计划
- 报告和跟踪问题
## 七、成功指标
### 1. 性能指标
| 指标 | 目标值 | 测量方法 |
|------|--------|----------|
| API响应时间 | <100ms | |
| 缓存命中率 | >90% | 缓存命中次数/总请求次数 |
| 数据同步延迟 | <5 | |
| 系统并发能力 | >1000用户 | 负载测试 |
| 数据库查询时间 | <50ms | |
### 2. 功能指标
| 指标 | 目标值 | 测量方法 |
|------|--------|----------|
| 数据一致性 | 100% | 缓存与数据库对比 |
| 系统可用性 | >99.9% | 系统运行时间 |
| 数据完整性 | 100% | 数据验证 |
| 功能覆盖率 | 100% | 测试用例覆盖 |
### 3. 业务指标
| 指标 | 目标值 | 测量方法 |
|------|--------|----------|
| 用户体验 | 提升50% | 用户反馈 |
| 分析速度 | 提升80% | 分析任务执行时间 |
| 数据更新频率 | 30秒/次 | 实时行情更新 |
| 系统稳定性 | 无重大故障 | 故障统计 |
## 八、总结
本实现步骤和迁移计划详细说明了股票分析系统引入数据库和Redis缓存的全过程包括基础设施搭建、代码修改、数据迁移、测试验证等环节。通过分阶段实施和灰度发布策略可以确保系统平稳过渡到新的架构同时降低迁移风险。
实施过程中,需要密切关注系统性能和数据一致性,及时处理出现的问题,确保系统的稳定性和可靠性。通过本计划的实施,可以显著提高系统性能,提升用户体验,为股票分析系统的长期发展奠定基础。

@ -0,0 +1,519 @@
# 性能优化方案
## 一、概述
性能优化是保证股票分析系统稳定运行和良好用户体验的关键。本方案从系统架构、数据库、缓存、API、前端等多个维度提出全面的性能优化策略旨在提高系统响应速度、降低资源消耗、提升用户体验。
## 二、系统架构优化
### 1. 分层架构
- **架构层次**
- 前端层React应用
- API层FastAPI接口
- 服务层:业务逻辑
- 数据层Redis缓存 + 数据库
- 数据源层外部API
- **优化策略**
- 减少层间通信开销
- 优化数据流转路径
- 实现服务解耦
### 2. 微服务化
- **服务拆分**
- 市场数据服务
- 分析计算服务
- 缓存服务
- 数据同步服务
- **优势**
- 独立部署和扩展
- 故障隔离
- 按需资源分配
### 3. 容器化部署
- **技术选型**
- Docker容器
- Kubernetes编排
- **优势**
- 环境一致性
- 快速部署和回滚
- 资源利用率高
## 三、数据库优化
### 1. 索引优化
- **优化策略**
- 为频繁查询的字段创建索引
- 使用复合索引加速多条件查询
- 定期重建索引
- **索引设计**
- `stock_kline(code, date)`:加速按股票和日期查询
- `sector_momentum(sector_code, date)`:加速按板块和日期查询
- `market_stats(date)`:加速按日期查询
### 2. 查询优化
- **优化策略**
- 使用预处理语句
- 避免SELECT *
- 合理使用JOIN
- 分页查询
- **示例**
```sql
-- 优化前
SELECT * FROM stock_kline WHERE code = '600519';
-- 优化后
SELECT date, open, high, low, close FROM stock_kline
WHERE code = '600519'
ORDER BY date DESC
LIMIT 30;
```
### 3. 存储优化
- **分区表**
- 按日期分区`stock_kline`表
- 提高查询性能
- 便于数据管理
- **数据压缩**
- 启用列压缩
- 减少存储空间
- 提高I/O性能
### 4. 连接池优化
- **配置优化**
- 合理设置连接池大小
- 调整连接超时时间
- 监控连接使用情况
- **技术选型**
- PostgreSQLPgBouncer
- MySQLProxySQL
## 四、Redis缓存优化
### 1. 内存优化
- **数据结构选择**
- 使用Hash存储对象
- 使用Sorted Set存储排序数据
- 使用List存储时间序列数据
- **内存配置**
- 设置合理的maxmemory
- 选择合适的内存淘汰策略
- 监控内存使用情况
### 2. 命令优化
- **批量操作**
- 使用Pipeline批量执行命令
- 使用MSET、MGET等批量命令
- 减少网络往返次数
- **避免阻塞**
- 避免使用KEYS命令
- 使用SCAN替代
- 避免大键操作
### 3. 部署优化
- **高可用**
- 主从复制
- 哨兵模式
- 集群模式
- **网络优化**
- 启用Redis持久化
- 优化网络配置
- 使用Unix Socket本地部署
## 五、API优化
### 1. 接口设计
- **RESTful设计**
- 合理的URL结构
- 标准的HTTP方法
- 一致的响应格式
- **参数优化**
- 合理的分页参数
- 缓存控制参数
- 字段过滤参数
### 2. 性能优化
- **异步处理**
- 使用FastAPI的异步特性
- 处理耗时操作
- 提高并发能力
- **响应优化**
- 压缩响应数据
- 合理设置缓存头
- 避免重复计算
### 3. 错误处理
- **统一错误处理**
- 标准的错误格式
- 详细的错误信息
- 合理的错误码
- **降级策略**
- 缓存失效时的降级
- 数据源失败时的降级
- 系统过载时的降级
## 六、前端优化
### 1. 代码优化
- **组件拆分**
- 合理拆分组件
- 减少组件渲染开销
- 提高代码可维护性
- **状态管理**
- 合理使用状态管理
- 避免不必要的状态更新
- 优化状态更新策略
### 2. 资源优化
- **静态资源**
- 压缩JS/CSS
- 使用CDN
- 资源缓存
- **图片优化**
- 合理的图片格式
- 图片压缩
- 懒加载
### 3. 渲染优化
- **虚拟滚动**
- 处理大量数据列表
- 减少DOM节点
- 提高滚动性能
- **防抖和节流**
- 优化用户输入
- 减少API调用
- 提高响应速度
### 4. 网络优化
- **HTTP/2**
- 启用HTTP/2
- 多路复用
- 头部压缩
- **API调用优化**
- 批量请求
- 缓存请求结果
- 合理的请求时机
## 七、数据处理优化
### 1. 数据获取优化
- **批量获取**
- 减少API调用次数
- 提高数据获取效率
- 降低数据源负载
- **并行处理**
- 并行获取数据
- 提高处理速度
- 充分利用多核CPU
### 2. 计算优化
- **预计算**
- 提前计算常用指标
- 减少实时计算开销
- 提高响应速度
- **增量计算**
- 只计算变化的数据
- 减少计算量
- 提高计算效率
### 3. 数据压缩
- **传输压缩**
- 使用GZIP压缩
- 减少网络传输量
- 提高传输速度
- **存储压缩**
- 压缩存储数据
- 减少存储空间
- 提高I/O性能
## 八、监控与调优
### 1. 监控体系
- **系统监控**
- CPU、内存、磁盘、网络
- 系统负载
- 进程状态
- **应用监控**
- API响应时间
- 错误率
- 请求量
- **数据库监控**
- 查询性能
- 连接数
- 缓存命中率
- **Redis监控**
- 内存使用
- 命令执行时间
- 命中率
### 2. 调优策略
- **性能分析**
- 使用性能分析工具
- 识别性能瓶颈
- 制定优化方案
- **A/B测试**
- 对比不同优化方案
- 选择最优方案
- 持续优化
- **自动调优**
- 基于监控数据自动调整
- 适应不同负载情况
- 提高系统稳定性
## 九、安全优化
### 1. 访问控制
- **API认证**
- 使用JWT认证
- 合理的权限控制
- 防止未授权访问
- **Rate Limiting**
- 限制API请求频率
- 防止恶意请求
- 保护系统资源
### 2. 数据安全
- **数据加密**
- 敏感数据加密
- 传输加密
- 存储加密
- **数据验证**
- 输入验证
- 防止SQL注入
- 防止XSS攻击
### 3. 系统安全
- **防火墙**
- 配置防火墙规则
- 限制访问IP
- 保护系统安全
- **漏洞扫描**
- 定期扫描系统漏洞
- 及时修复
- 提高系统安全性
## 十、扩展性考虑
### 1. 水平扩展
- **服务扩展**
- 无状态服务水平扩展
- 负载均衡
- 提高系统容量
- **数据扩展**
- 数据库分片
- Redis集群
- 支持大数据量
### 2. 垂直扩展
- **资源升级**
- 增加服务器资源
- 优化配置
- 提高单节点性能
- **代码优化**
- 算法优化
- 数据结构优化
- 提高代码效率
### 3. 技术选型
- **未来兼容**
- 选择成熟的技术栈
- 考虑技术发展趋势
- 避免技术债务
- **可替代性**
- 模块化设计
- 接口标准化
- 便于技术升级
## 十一、性能测试
### 1. 测试方法
- **负载测试**
- 模拟并发用户
- 测试系统极限
- 识别性能瓶颈
- **压力测试**
- 持续高负载
- 测试系统稳定性
- 验证系统恢复能力
- **性能基准测试**
- 建立性能基准
- 对比优化效果
- 指导优化方向
### 2. 测试工具
- **API测试**
- JMeter
- Locust
- k6
- **数据库测试**
- pgbench
- sysbench
- **前端测试**
- Lighthouse
- WebPageTest
### 3. 测试指标
| 指标 | 目标值 | 测量方法 |
|------|--------|----------|
| API响应时间 | <100ms | |
| 页面加载时间 | <2s | |
| 并发用户数 | >1000 | 系统稳定运行 |
| 数据库查询时间 | <50ms | |
| 缓存命中率 | >90% | 缓存命中次数/总请求次数 |
## 十二、实施计划
### 1. 短期优化1-2周
- **数据库索引优化**
- 创建必要的索引
- 优化查询语句
- **Redis缓存优化**
- 调整缓存策略
- 优化数据结构
- **API优化**
- 实现异步处理
- 优化响应格式
### 2. 中期优化1-2个月
- **系统架构优化**
- 服务拆分
- 容器化部署
- **数据处理优化**
- 实现批量处理
- 优化计算逻辑
- **监控体系**
- 建立监控系统
- 实施告警策略
### 3. 长期优化3-6个月
- **微服务架构**
- 完成服务拆分
- 实现服务治理
- **大数据处理**
- 优化数据存储
- 实现数据分片
- **智能优化**
- 基于AI的自动调优
- 预测性维护
## 十三、预期效果
### 1. 性能提升
- **响应速度**
- API响应时间减少50%
- 页面加载时间减少60%
- 数据查询速度提升70%
- **系统容量**
- 并发用户数提升5倍
- 数据处理能力提升10倍
- 系统稳定性显著提高
### 2. 资源利用
- **CPU利用率**
- 更合理的CPU使用
- 减少不必要的计算
- **内存使用**
- 优化内存分配
- 减少内存泄漏
- **存储使用**
- 压缩存储数据
- 合理的数据清理策略
### 3. 用户体验
- **界面响应**
- 更流畅的操作体验
- 减少加载等待时间
- **功能可用性**
- 更高的系统可用性
- 更少的错误和异常
- **数据准确性**
- 更准确的市场数据
- 更及时的信息更新
## 十四、总结
性能优化是一个持续的过程需要从系统架构、数据库、缓存、API、前端等多个维度进行全面优化。通过本方案的实施可以显著提高系统性能提升用户体验为股票分析系统的稳定运行和未来发展奠定基础。
同时,性能优化需要结合实际情况,根据系统的特点和业务需求,制定合理的优化策略。通过持续监控和调优,不断提升系统性能,以适应不断变化的业务需求和技术挑战。

@ -0,0 +1,348 @@
# Redis缓存架构设计
## 一、设计概述
本Redis缓存架构用于存储股票分析系统的热点数据减少对数据源和数据库的访问提高系统响应速度。设计采用Redis作为缓存层主要存储以下数据
- 实时行情数据
- 板块动量数据
- 市场统计数据
- K线历史数据
- 计算结果缓存
## 二、缓存键设计
### 1. 实时行情缓存
| 缓存类型 | 缓存键格式 | 数据结构 | 过期时间 | 描述 |
|---------|-----------|---------|---------|------|
| 单只股票实时行情 | `realtime:stock:{code}` | Hash | 30分钟 | 存储单只股票的实时行情数据 |
| 全市场实时行情 | `realtime:full` | String | 30分钟 | 存储全市场股票的实时行情数据(压缩) |
| ETF实时行情 | `realtime:etf:{code}` | Hash | 30分钟 | 存储ETF的实时行情数据 |
| 实时行情批次 | `realtime:batch` | Set | 1小时 | 记录最近更新的股票代码 |
### 2. 板块数据缓存
| 缓存类型 | 缓存键格式 | 数据结构 | 过期时间 | 描述 |
|---------|-----------|---------|---------|------|
| 板块动量 | `sector:momentum:{period}:{sort}:{order}:{limit}` | Sorted Set | 1小时 | 存储板块动量数据,按动量值排序 |
| 板块成分股 | `sector:stocks:{sector_code}` | List | 24小时 | 存储板块的成分股列表 |
| 板块基本信息 | `sector:info:{sector_code}` | Hash | 24小时 | 存储板块的基本信息 |
### 3. 市场数据缓存
| 缓存类型 | 缓存键格式 | 数据结构 | 过期时间 | 描述 |
|---------|-----------|---------|---------|------|
| 市场统计 | `market:stats` | Hash | 1小时 | 存储市场统计数据 |
| 指数数据 | `market:index:{code}` | Hash | 1小时 | 存储指数的实时数据 |
| 涨跌分布 | `market:distribution` | Hash | 1小时 | 存储市场涨跌分布数据 |
### 4. K线数据缓存
| 缓存类型 | 缓存键格式 | 数据结构 | 过期时间 | 描述 |
|---------|-----------|---------|---------|------|
| K线数据 | `kline:{code}:{period}:{days}` | List | 1天 | 存储股票的K线数据 |
| K线数据压缩 | `kline:compressed:{code}:{period}:{days}` | String | 1天 | 存储压缩后的K线数据 |
### 5. 计算结果缓存
| 缓存类型 | 缓存键格式 | 数据结构 | 过期时间 | 描述 |
|---------|-----------|---------|---------|------|
| 技术指标 | `indicator:{code}:{indicator}:{params}` | Hash | 1天 | 存储计算好的技术指标 |
| 选股结果 | `stock:selection:{strategy}:{params}` | Set | 1小时 | 存储选股策略的结果 |
| 回测结果 | `backtest:{strategy}:{params}` | Hash | 24小时 | 存储回测结果 |
## 三、数据结构选择
### 1. Hash
- **适用场景**:存储结构化数据,如实时行情、板块信息
- **优点**
- 支持字段级操作,节省内存
- 适合存储对象类型数据
- 查找速度快
- **示例**
```
HSET realtime:stock:600519 price 1800.00 change_pct 2.50 volume 1000000
HGETALL realtime:stock:600519
```
### 2. Sorted Set
- **适用场景**:需要排序的数据,如板块动量排名
- **优点**
- 自动排序
- 支持范围查询
- 适合Top-N查询
- **示例**
```
ZADD sector:momentum:5:desc:20 12.5 HY001 10.2 HY002 8.7 HY003
ZRANGE sector:momentum:5:desc:20 0 9 WITHSCORES
```
### 3. List
- **适用场景**存储有序数据如K线数据、成分股列表
- **优点**
- 支持顺序访问
- 适合存储时间序列数据
- 支持范围查询
- **示例**
```
LPUSH kline:600519:1d:30 {"date":"2023-01-01","open":1700,"high":1800,"low":1650,"close":1750}
LRANGE kline:600519:1d:30 0 -1
```
### 4. Set
- **适用场景**:存储唯一数据,如股票代码集合
- **优点**
- 自动去重
- 支持集合操作
- 适合存储标签类数据
- **示例**
```
SADD sector:stocks:HY001 600000 601398 601288
SMEMBERS sector:stocks:HY001
```
### 5. String
- **适用场景**:存储任意数据,如全市场实时行情(压缩)
- **优点**
- 灵活存储任意格式数据
- 支持二进制数据
- 适合存储大体积数据
- **示例**
```
SET realtime:full "{compressed json data}" EX 1800
GET realtime:full
```
## 四、缓存策略
### 1. 写入策略
#### 实时数据
- **主动更新**定时任务每30秒拉取全市场数据更新Redis
- **惰性更新**:当缓存过期时,请求触发更新
- **批量更新**使用Pipeline批量写入减少网络往返
#### 计算数据
- **预计算**:定时任务提前计算板块动量、技术指标等
- **按需计算**:首次请求时计算并缓存
- **增量计算**:只更新变化的数据
### 2. 读取策略
- **优先缓存**先从Redis读取未命中再从数据库读取
- **缓存预热**:服务启动时预热热门数据
- **降级策略**Redis不可用时直接从数据库读取
### 3. 过期策略
- **时间过期**:设置合理的过期时间
- **主动清理**:定期清理过期数据
- **内存限制**设置Redis最大内存使用LRU策略
### 4. 缓存一致性
- **先更新数据库,后更新缓存**:确保数据一致性
- **缓存标记失效**:更新数据库后,标记缓存失效,下次读取时更新
- **定期全量同步**:定时全量同步数据,确保最终一致性
## 五、部署方案
### 1. 单节点部署
- **适用场景**:开发/测试环境
- **配置**
- 内存4GB+
- 端口6379
- 持久化RDB + AOF
### 2. 主从复制
- **适用场景**:生产环境
- **配置**
- 主节点:负责写入
- 从节点:负责读取
- 复制方式:异步复制
### 3. 哨兵模式
- **适用场景**:生产环境,需要高可用性
- **配置**
- 3个哨兵节点
- 自动故障转移
- 监控主从状态
### 4. 集群模式
- **适用场景**:大数据量场景,需要水平扩展
- **配置**
- 3个主节点3个从节点
- 数据分片
- 自动重平衡
## 六、性能优化
### 1. 内存优化
- **数据压缩**使用Redis的压缩列表
- **键空间优化**:使用哈希标签减少键冲突
- **内存淘汰策略**:设置合适的内存淘汰策略
### 2. 网络优化
- **Pipeline**:批量执行命令,减少网络往返
- **连接池**使用连接池管理Redis连接
- **批量操作**使用MSET、MGET等批量命令
### 3. 命令优化
- **使用Hash存储对象**:减少键数量
- **使用Sorted Set排序**利用Redis内置排序
- **避免使用KEYS命令**使用SCAN替代
### 4. 监控与调优
- **监控指标**
- 内存使用情况
- 命中率
- 命令执行时间
- 连接数
- **调优参数**
- maxmemory
- maxmemory-policy
- hash-max-ziplist-entries
- hash-max-ziplist-value
## 七、缓存工具类设计
### 1. 缓存操作接口
```python
class RedisCache:
def get(self, key):
"""获取缓存"""
pass
def set(self, key, value, expire=None):
"""设置缓存"""
pass
def delete(self, key):
"""删除缓存"""
pass
def hash_get(self, key, field):
"""获取Hash字段"""
pass
def hash_set(self, key, field, value):
"""设置Hash字段"""
pass
def sorted_set_add(self, key, score, member):
"""添加到有序集合"""
pass
def sorted_set_range(self, key, start, end, withscores=False):
"""获取有序集合范围"""
pass
```
### 2. 缓存键生成器
```python
class CacheKeyGenerator:
@staticmethod
def get_stock_realtime_key(code):
return f"realtime:stock:{code}"
@staticmethod
def get_sector_momentum_key(period, sort, order, limit):
return f"sector:momentum:{period}:{sort}:{order}:{limit}"
@staticmethod
def get_kline_key(code, period, days):
return f"kline:{code}:{period}:{days}"
```
### 3. 缓存管理器
```python
class CacheManager:
def __init__(self, redis_client):
self.redis = redis_client
self.key_generator = CacheKeyGenerator()
def get_stock_realtime(self, code):
"""获取股票实时行情"""
key = self.key_generator.get_stock_realtime_key(code)
return self.redis.get(key)
def set_stock_realtime(self, code, data, expire=1800):
"""设置股票实时行情"""
key = self.key_generator.get_stock_realtime_key(code)
return self.redis.set(key, data, expire)
def get_sector_momentum(self, period, sort, order, limit):
"""获取板块动量"""
key = self.key_generator.get_sector_momentum_key(period, sort, order, limit)
return self.redis.sorted_set_range(key, 0, limit-1, withscores=True)
```
## 八、监控与告警
### 1. 监控指标
| 指标 | 描述 | 阈值 |
|------|------|------|
| 内存使用率 | Redis内存使用百分比 | >80% |
| 命中率 | 缓存命中次数/总请求次数 | <80% |
| 连接数 | 当前Redis连接数 | >1000 |
| 命令执行时间 | 平均命令执行时间 | >1ms |
| 过期键数量 | 过期键占比 | >50% |
### 2. 告警策略
- **内存告警**内存使用率超过80%时告警
- **命中率告警**命中率低于80%时告警
- **连接数告警**连接数超过1000时告警
- **错误率告警**命令错误率超过1%时告警
### 3. 监控工具
- **Prometheus**收集Redis指标
- **Grafana**:可视化监控面板
- **Redis Exporter**导出Redis指标
- **Alertmanager**:处理告警
## 九、安全考虑
### 1. 访问控制
- **密码认证**设置Redis密码
- **IP白名单**限制访问IP
- **ACL**使用Redis 6.0+的ACL功能
### 2. 数据安全
- **数据加密**:对敏感数据进行加密
- **定期备份**定期备份Redis数据
- **灾难恢复**:制定灾难恢复计划
### 3. 网络安全
- **网络隔离**Redis服务放在内网
- **SSL加密**使用SSL加密传输
- **防火墙**:配置防火墙规则
## 十、扩展性考虑
### 1. 数据分片
- **水平分片**:按股票代码范围分片
- **垂直分片**:按数据类型分片
- **一致性哈希**:使用一致性哈希算法
### 2. 读写分离
- **主节点**:处理写入请求
- **从节点**:处理读取请求
- **负载均衡**:使用负载均衡器分发请求
### 3. 集群扩展
- **自动扩缩容**:根据负载自动扩缩容
- **滚动升级**:支持无停机升级
- **跨区域部署**:支持多区域部署
## 十一、总结
Redis缓存架构设计采用多种数据结构和缓存策略为股票分析系统提供高性能、高可靠性的数据访问层。通过合理的缓存键设计、数据结构选择和缓存策略可以显著减少对数据源和数据库的访问提高系统响应速度同时保证数据的一致性和可靠性。
该设计不仅满足当前系统的需求,还为未来的扩展和优化预留了空间,能够应对数据量增长和业务需求变化的挑战。

@ -0,0 +1,118 @@
-- 创建数据库(如果不存在)
CREATE DATABASE IF NOT EXISTS stock_analysis;
-- 使用数据库
USE stock_analysis;
-- 股票基本信息表
CREATE TABLE IF NOT EXISTS stock_basic (
code VARCHAR(10) PRIMARY KEY,
name VARCHAR(50) NOT NULL,
sector_code VARCHAR(10),
list_date DATE,
is_etf BOOLEAN DEFAULT FALSE,
market VARCHAR(10),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 股票实时行情表
CREATE TABLE IF NOT EXISTS stock_quote (
code VARCHAR(10) PRIMARY KEY,
price DECIMAL(10,2),
change_pct DECIMAL(6,2),
change_amount DECIMAL(10,2),
volume BIGINT,
amount DECIMAL(18,2),
turnover_rate DECIMAL(8,4),
amplitude DECIMAL(6,2),
high DECIMAL(10,2),
low DECIMAL(10,2),
open DECIMAL(10,2),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 股票K线数据表
CREATE TABLE IF NOT EXISTS stock_kline (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
code VARCHAR(10) NOT NULL,
date DATE NOT NULL,
open DECIMAL(10,2),
high DECIMAL(10,2),
low DECIMAL(10,2),
close DECIMAL(10,2),
volume BIGINT,
amount DECIMAL(18,2),
pct_chg DECIMAL(6,2),
INDEX idx_code_date (code, date)
);
-- 板块信息表
CREATE TABLE IF NOT EXISTS sector_info (
code VARCHAR(10) PRIMARY KEY,
name VARCHAR(50) NOT NULL,
stock_count INT DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 板块动量表
CREATE TABLE IF NOT EXISTS sector_momentum (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
sector_code VARCHAR(10) NOT NULL,
date DATE NOT NULL,
momentum_value DECIMAL(10,4),
n INT,
N INT,
rank INT,
rank_change INT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_sector_date (sector_code, date)
);
-- 市场统计表
CREATE TABLE IF NOT EXISTS market_stats (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
date DATE NOT NULL UNIQUE,
up_count INT DEFAULT 0,
down_count INT DEFAULT 0,
flat_count INT DEFAULT 0,
limit_up_count INT DEFAULT 0,
limit_down_count INT DEFAULT 0,
total_amount DECIMAL(20,2),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 创建外键约束(可选)
-- ALTER TABLE stock_basic ADD FOREIGN KEY (sector_code) REFERENCES sector_info(code);
-- ALTER TABLE sector_momentum ADD FOREIGN KEY (sector_code) REFERENCES sector_info(code);
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_stock_basic_sector ON stock_basic(sector_code);
CREATE INDEX IF NOT EXISTS idx_sector_momentum_rank ON sector_momentum(rank);
CREATE INDEX IF NOT EXISTS idx_market_stats_date ON market_stats(date);
-- 插入示例数据
-- 板块信息
INSERT IGNORE INTO sector_info (code, name, stock_count) VALUES
('HY001', '银行', 42),
('HY002', '证券', 48),
('HY003', '保险', 10),
('HY004', '房地产', 130),
('HY005', '医药', 300),
('HY006', '科技', 500),
('HY007', '消费', 200),
('HY008', '能源', 80),
('HY009', '材料', 150),
('HY010', '工业', 250);
-- 股票基本信息(示例)
INSERT IGNORE INTO stock_basic (code, name, sector_code, is_etf, market) VALUES
('600000', '浦发银行', 'HY001', FALSE, '沪市'),
('600519', '贵州茅台', 'HY007', FALSE, '沪市'),
('000001', '平安银行', 'HY001', FALSE, '深市'),
('000858', '五粮液', 'HY007', FALSE, '深市'),
('510300', '沪深300ETF', 'HY006', TRUE, '沪市'),
('510500', '500ETF', 'HY006', TRUE, '沪市');
-- 市场统计(示例)
INSERT IGNORE INTO market_stats (date, up_count, down_count, flat_count, limit_up_count, limit_down_count, total_amount) VALUES
(CURDATE(), 2500, 2000, 500, 100, 20, 1500000000000);

@ -59,6 +59,13 @@ class Config:
# === 数据源 API Token ===
tushare_token: Optional[str] = None
# === AmazingData 自定义数据源配置 ===
amazingdata_base_url: Optional[str] = None
amazingdata_username: Optional[str] = None
amazingdata_password: Optional[str] = None
amazingdata_host: Optional[str] = None
amazingdata_port: Optional[int] = None
# === AI 分析配置 ===
gemini_api_key: Optional[str] = None
gemini_model: str = "gemini-3-flash-preview" # 主模型
@ -355,6 +362,11 @@ class Config:
feishu_app_secret=os.getenv('FEISHU_APP_SECRET'),
feishu_folder_token=os.getenv('FEISHU_FOLDER_TOKEN'),
tushare_token=os.getenv('TUSHARE_TOKEN'),
amazingdata_base_url=os.getenv('AMAZINGDATA_BASE_URL'),
amazingdata_username=os.getenv('AMAZINGDATA_USERNAME'),
amazingdata_password=os.getenv('AMAZINGDATA_PASSWORD'),
amazingdata_host=os.getenv('AMAZINGDATA_HOST'),
amazingdata_port=int(os.getenv('AMAZINGDATA_PORT', '8600')) if os.getenv('AMAZINGDATA_PORT') else None,
gemini_api_key=os.getenv('GEMINI_API_KEY'),
gemini_model=os.getenv('GEMINI_MODEL', 'gemini-3-flash-preview'),
gemini_model_fallback=os.getenv('GEMINI_MODEL_FALLBACK', 'gemini-2.5-flash'),

@ -0,0 +1,912 @@
# -*- coding: utf-8 -*-
"""
===================================
Market Service Layer
===================================
Responsibilities:
1. Provide market indices data
2. Provide sector momentum data
3. Provide stock momentum recommendations
4. Provide new high/low stocks
5. Provide price distribution
6. Provide K-line chart data
"""
import logging
import random
import json
from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any
from api.v1.schemas.market import (
MarketIndex,
UpDownStats,
SectorMomentum,
StockMomentum,
NewHighLowStock,
PriceDistribution,
PriceDistributionResponse,
SentimentIndicator,
KLineChartResponse,
)
logger = logging.getLogger(__name__)
class MarketService:
"""
Market data service
Provides market overview, sector momentum, stock recommendations, etc.
"""
MAJOR_INDICES = [
{"code": "000001.SH", "name": "上证指数"},
{"code": "399001.SZ", "name": "深证成指"},
{"code": "399006.SZ", "name": "创业板指"},
{"code": "000698.SH", "name": "科创50"},
]
SECTOR_NAMES = {
"BK0001": "计算机",
"BK0002": "通信",
"BK0003": "电子",
"BK0004": "医药生物",
"BK0005": "食品饮料",
"BK0006": "家电",
"BK0007": "汽车",
"BK0008": "机械设备",
"BK0009": "化工",
"BK0010": "有色金属",
"BK0011": "钢铁",
"BK0012": "建筑材料",
"BK0013": "房地产",
"BK0014": "银行",
"BK0015": "非银金融",
"BK0016": "传媒",
"BK0017": "互联网",
"BK0018": "商贸零售",
"BK0019": "公用事业",
"BK0020": "纺织服饰",
}
# 板块股票数量映射(模拟数据)
SECTOR_STOCK_COUNT = {
"BK0001": 250, # 计算机
"BK0002": 80, # 通信
"BK0003": 300, # 电子
"BK0004": 350, # 医药生物
"BK0005": 120, # 食品饮料
"BK0006": 60, # 家电
"BK0007": 150, # 汽车
"BK0008": 200, # 机械设备
"BK0009": 180, # 化工
"BK0010": 100, # 有色金属
"BK0011": 50, # 钢铁
"BK0012": 70, # 建筑材料
"BK0013": 120, # 房地产
"BK0014": 40, # 银行
"BK0015": 80, # 非银金融
"BK0016": 100, # 传媒
"BK0017": 50, # 互联网
"BK0018": 90, # 商贸零售
"BK0019": 60, # 公用事业
"BK0020": 80, # 纺织服饰
}
CACHE_KEYS = {
"sector_momentum": "sector_momentum",
"sector_ranking": "sector_ranking",
"stock_momentum": "stock_momentum",
}
CACHE_EXPIRY = 3600 # 1小时
def __init__(self):
"""Initialize market service"""
self._cache: Dict[str, Any] = {}
self._cache_time: Optional[Dict[str, float]] = {}
self._previous_sector_data: Dict[str, Any] = {}
def get_indices(self) -> List[MarketIndex]:
"""
Get major market indices
Returns list of major indices with current prices
"""
try:
indices = []
for idx in self.MAJOR_INDICES:
quote = self._get_index_quote(idx["code"])
indices.append(MarketIndex(
code=idx["code"],
name=idx["name"],
price=quote.get("price", 0.0),
change=quote.get("change"),
change_percent=quote.get("change_percent"),
volume=quote.get("volume"),
amount=quote.get("amount")
))
return indices
except Exception as e:
logger.error(f"Failed to get indices: {e}", exc_info=True)
return []
def get_updown_stats(self) -> UpDownStats:
"""
Get up/down statistics
Returns market breadth statistics
"""
try:
stats = self._fetch_updown_stats()
return UpDownStats(
up_count=stats.get("up_count", 0),
down_count=stats.get("down_count", 0),
flat_count=stats.get("flat_count", 0),
limit_up_count=stats.get("limit_up_count", 0),
limit_down_count=stats.get("limit_down_count", 0),
total_count=stats.get("total_count", 0)
)
except Exception as e:
logger.error(f"Failed to get updown stats: {e}", exc_info=True)
return UpDownStats(
up_count=0, down_count=0, flat_count=0,
limit_up_count=0, limit_down_count=0, total_count=0
)
def get_price_distribution(self) -> PriceDistributionResponse:
"""
Get price distribution
Returns distribution of stocks across price ranges
"""
try:
distribution = self._fetch_price_distribution()
items = [
PriceDistribution(
range_label=item.get("range_label", ""),
count=item.get("count", 0),
percent=item.get("percent", 0.0)
)
for item in distribution
]
total_count = sum(item.count for item in items)
return PriceDistributionResponse(items=items, total_count=total_count)
except Exception as e:
logger.error(f"Failed to get price distribution: {e}", exc_info=True)
return PriceDistributionResponse(items=[], total_count=0)
def get_sentiment_indicators(self) -> List[SentimentIndicator]:
"""
Get sentiment indicators
Returns market sentiment data
"""
try:
indicators = self._fetch_sentiment()
return [
SentimentIndicator(
name=ind.get("name", ""),
value=ind.get("value", 0.0),
change=ind.get("change"),
level=ind.get("level"),
description=ind.get("description")
)
for ind in indicators
]
except Exception as e:
logger.error(f"Failed to get sentiment: {e}", exc_info=True)
return []
def get_sector_momentum(
self,
sort: str = "momentumScore",
order: str = "desc",
limit: int = 20,
period: int = 5 # 5日涨跌幅
) -> List[SectorMomentum]:
"""
Get sector momentum ranking
Args:
sort: Sort field
order: Sort order
limit: Limit count
period: Period for calculating momentum (days)
Returns:
List of sector momentum data
"""
try:
# 尝试从缓存获取
cache_key = f"{self.CACHE_KEYS['sector_momentum']}_{period}"
cached = self._get_from_cache(cache_key)
if cached:
return cached
# 计算板块动量
sectors = self._calculate_sector_momentum(period)
if sort == "momentumScore":
sectors.sort(key=lambda x: x.get("momentum_score", 0), reverse=(order == "desc"))
elif sort == "momentumValue":
sectors.sort(key=lambda x: x.get("momentum_value", 0), reverse=(order == "desc"))
elif sort == "changePercent":
sectors.sort(key=lambda x: x.get("change_percent", 0), reverse=(order == "desc"))
elif sort == "rankChange":
sectors.sort(key=lambda x: x.get("rank_change", 0), reverse=(order == "desc"))
sectors = sectors[:limit]
# 转换为模型
result = [
SectorMomentum(
code=s.get("code", ""),
name=s.get("name", ""),
momentum_score=s.get("momentum_score", 0.0),
change_percent=s.get("change_percent", 0.0),
turnover_rate=s.get("turnover_rate"),
leading_stock=s.get("leading_stock"),
leading_stock_name=s.get("leading_stock_name"),
momentum_value=s.get("momentum_value"),
momentum_value_change=s.get("momentum_value_change"),
rank=s.get("rank"),
rank_change=s.get("rank_change")
)
for s in sectors
]
# 缓存结果
self._set_to_cache(cache_key, result)
return result
except Exception as e:
logger.error(f"Failed to get sector momentum: {e}", exc_info=True)
return []
def _calculate_sector_momentum(self, period: int = 5) -> List[Dict[str, Any]]:
"""
Calculate sector momentum based on the specified algorithm
Algorithm:
1. Get all stocks with their price change percentage for the period
2. Sort by change percentage
3. Take top 16% as momentum stocks
4. Group by sector
5. Calculate momentum value = () / N, where:
- n: number of momentum stocks in sector
- N: total stocks in sector
6. Calculate momentum value change and rank change
"""
# Step 1: Get all stocks with their change percentages
all_stocks = self._fetch_all_stocks_with_change(period)
if not all_stocks:
return self._generate_mock_sector_momentum()
# Step 2: Sort by change percentage (descending)
all_stocks.sort(key=lambda x: x.get("change_percent", 0), reverse=True)
# Step 3: Take top 16%
top_16_percent_count = max(1, int(len(all_stocks) * 0.16))
momentum_stocks = all_stocks[:top_16_percent_count]
# Step 4: Group by sector
sector_momentum_map = {}
for stock in momentum_stocks:
sector = stock.get("sector")
if sector:
if sector not in sector_momentum_map:
sector_momentum_map[sector] = []
sector_momentum_map[sector].append(stock)
# Step 5: Calculate momentum value for each sector
sector_data = []
for sector_code, stocks in sector_momentum_map.items():
n = len(stocks)
N = self.SECTOR_STOCK_COUNT.get(sector_code, 1) # Default to 1 to avoid division by zero
momentum_value = (n ** 2) / N if N > 0 else 0
sector_info = {
"code": sector_code,
"name": self.SECTOR_NAMES.get(sector_code, sector_code),
"momentum_value": round(momentum_value, 4),
"n": n, # Number of momentum stocks in sector
"N": N, # Total stocks in sector
"stocks": stocks
}
sector_data.append(sector_info)
# Add sectors with no momentum stocks
for sector_code, sector_name in self.SECTOR_NAMES.items():
if sector_code not in sector_momentum_map:
N = self.SECTOR_STOCK_COUNT.get(sector_code, 1)
sector_data.append({
"code": sector_code,
"name": sector_name,
"momentum_value": 0.0,
"n": 0,
"N": N,
"stocks": []
})
# Step 6: Sort by momentum value (descending)
sector_data.sort(key=lambda x: x.get("momentum_value", 0), reverse=True)
# Step 7: Calculate ranks and changes
current_ranking = {sector["code"]: i + 1 for i, sector in enumerate(sector_data)}
# Get previous data
previous_data = self._previous_sector_data.get(period, {})
previous_ranking = previous_data.get("ranking", {})
previous_momentum = previous_data.get("momentum", {})
# Calculate changes and add additional fields
for i, sector in enumerate(sector_data):
sector_code = sector["code"]
current_rank = i + 1
previous_rank = previous_ranking.get(sector_code, current_rank)
rank_change = previous_rank - current_rank # Positive means rank improved
current_momentum = sector["momentum_value"]
previous_momentum_value = previous_momentum.get(sector_code, current_momentum)
momentum_value_change = current_momentum - previous_momentum_value
# Calculate additional metrics
if sector["stocks"]:
avg_change = sum(s.get("change_percent", 0) for s in sector["stocks"]) / len(sector["stocks"])
leading_stock = max(sector["stocks"], key=lambda s: s.get("change_percent", 0))
turnover_rate = random.uniform(1, 10)
else:
avg_change = 0
leading_stock = None
turnover_rate = random.uniform(0.5, 3)
sector.update({
"rank": current_rank,
"rank_change": rank_change,
"momentum_value_change": round(momentum_value_change, 4),
"momentum_score": round(sector["momentum_value"] * 100, 2),
"change_percent": round(avg_change, 2),
"turnover_rate": round(turnover_rate, 2),
"leading_stock": leading_stock.get("code") if leading_stock else None,
"leading_stock_name": leading_stock.get("name") if leading_stock else None
})
# Update previous data for next calculation
self._previous_sector_data[period] = {
"ranking": current_ranking,
"momentum": {sector["code"]: sector["momentum_value"] for sector in sector_data},
"timestamp": datetime.now().isoformat()
}
return sector_data
def _fetch_all_stocks_with_change(self, period: int = 5) -> List[Dict[str, Any]]:
"""
Fetch all stocks with their price change percentage for the specified period
Args:
period: Number of days for calculating change
Returns:
List of stocks with change percentage and sector
"""
# In a real implementation, this would fetch data from a data provider
# For now, we'll generate mock data
stocks = []
sector_codes = list(self.SECTOR_NAMES.keys())
# Generate mock stocks for each sector
for sector_code in sector_codes:
stock_count = self.SECTOR_STOCK_COUNT.get(sector_code, 50)
for i in range(stock_count):
stock = {
"code": f"{random.randint(600000, 605000) if random.random() > 0.5 else random.randint(0, 300000):06d}",
"name": f"{self.SECTOR_NAMES.get(sector_code, sector_code)}股票{i+1}",
"change_percent": random.uniform(-10, 15), # Random change percentage
"sector": sector_code
}
stocks.append(stock)
return stocks
def get_stock_momentum_recommendation(self, limit: int = 15) -> List[StockMomentum]:
"""
Get stock momentum recommendations
Args:
limit: Limit count
Returns:
List of recommended stocks
"""
try:
cache_key = self.CACHE_KEYS['stock_momentum']
cached = self._get_from_cache(cache_key)
if cached:
return cached[:limit]
stocks = self._fetch_stock_momentum()
stocks = stocks[:limit]
result = [
StockMomentum(
code=s.get("code", ""),
name=s.get("name", ""),
momentum_score=s.get("momentum_score", 0.0),
change_percent=s.get("change_percent", 0.0),
sector=s.get("sector"),
recommendation=s.get("recommendation")
)
for s in stocks
]
self._set_to_cache(cache_key, result)
return result
except Exception as e:
logger.error(f"Failed to get stock momentum: {e}", exc_info=True)
return []
def get_new_high_stocks(self, days: int = 20, limit: int = 20) -> List[NewHighLowStock]:
"""
Get stocks that reached new high
Args:
days: Days to look back
limit: Limit count
Returns:
List of new high stocks
"""
try:
stocks = self._fetch_new_high_low("high", days)
stocks = stocks[:limit]
return [
NewHighLowStock(
code=s.get("code", ""),
name=s.get("name", ""),
price=s.get("price", 0.0),
change_percent=s.get("change_percent", 0.0),
days_to_high=s.get("days_to_high"),
high_date=s.get("high_date")
)
for s in stocks
]
except Exception as e:
logger.error(f"Failed to get new high stocks: {e}", exc_info=True)
return []
def get_new_low_stocks(self, days: int = 20, limit: int = 20) -> List[NewHighLowStock]:
"""
Get stocks that reached new low
Args:
days: Days to look back
limit: Limit count
Returns:
List of new low stocks
"""
try:
stocks = self._fetch_new_high_low("low", days)
stocks = stocks[:limit]
return [
NewHighLowStock(
code=s.get("code", ""),
name=s.get("name", ""),
price=s.get("price", 0.0),
change_percent=s.get("change_percent", 0.0),
days_to_low=s.get("days_to_low"),
low_date=s.get("low_date")
)
for s in stocks
]
except Exception as e:
logger.error(f"Failed to get new low stocks: {e}", exc_info=True)
return []
def get_stock_kline_chart(self, stock_code: str, days: int = 60) -> KLineChartResponse:
"""
Get stock K-line chart data for ECharts
Args:
stock_code: Stock code
days: Number of days
Returns:
K-line chart data formatted for ECharts
"""
try:
kline_data = self._fetch_kline_data(stock_code, days)
if not kline_data:
return KLineChartResponse(
categoryData=[], values=[], volumes=[], stock_name=None
)
category_data = []
values = []
volumes = []
for i, item in enumerate(kline_data):
category_data.append(item.get("date", ""))
values.append([
item.get("open", 0.0),
item.get("close", 0.0),
item.get("low", 0.0),
item.get("high", 0.0),
item.get("volume", 0.0)
])
change_sign = 1 if item.get("close", 0) >= item.get("open", 0) else -1
volumes.append([i, item.get("volume", 0.0), change_sign])
stock_name = self._get_stock_name(stock_code)
return KLineChartResponse(
categoryData=category_data,
values=values,
volumes=volumes,
stock_name=stock_name
)
except Exception as e:
logger.error(f"Failed to get stock kline: {e}", exc_info=True)
return KLineChartResponse(
categoryData=[], values=[], volumes=[], stock_name=None
)
def get_sector_kline(self, sector_code: str, days: int = 60) -> KLineChartResponse:
"""
Get sector K-line chart
Args:
sector_code: Sector code
days: Number of days
Returns:
K-line chart data
"""
try:
kline_data = self._fetch_sector_kline_data(sector_code, days)
if not kline_data:
return KLineChartResponse(
categoryData=[], values=[], volumes=[], stock_name=None
)
category_data = []
values = []
volumes = []
for i, item in enumerate(kline_data):
category_data.append(item.get("date", ""))
values.append([
item.get("open", 0.0),
item.get("close", 0.0),
item.get("low", 0.0),
item.get("high", 0.0),
item.get("volume", 0.0)
])
change_sign = 1 if item.get("close", 0) >= item.get("open", 0) else -1
volumes.append([i, item.get("volume", 0.0), change_sign])
sector_name = self.SECTOR_NAMES.get(sector_code, sector_code)
return KLineChartResponse(
categoryData=category_data,
values=values,
volumes=volumes,
stock_name=sector_name
)
except Exception as e:
logger.error(f"Failed to get sector kline: {e}", exc_info=True)
return KLineChartResponse(
categoryData=[], values=[], volumes=[], stock_name=None
)
def _get_index_quote(self, code: str) -> Dict[str, Any]:
"""Get index quote from data provider"""
try:
from data_provider.base import DataFetcherManager
manager = DataFetcherManager()
quote = manager.get_realtime_quote(code)
if quote:
return {
"price": getattr(quote, "price", 0.0) or 0.0,
"change": getattr(quote, "change_amount", None),
"change_percent": getattr(quote, "change_pct", None),
"volume": getattr(quote, "volume", None),
"amount": getattr(quote, "amount", None),
}
except Exception as e:
logger.warning(f"Failed to get index quote for {code}: {e}")
return self._generate_mock_index_quote(code)
def _generate_mock_index_quote(self, code: str) -> Dict[str, Any]:
"""Generate mock index quote for testing"""
base_prices = {
"000001.SH": 3200.0,
"399001.SZ": 10500.0,
"399006.SZ": 2100.0,
"000698.SH": 950.0,
}
base_price = base_prices.get(code, 1000.0)
change_pct = random.uniform(-1.5, 2.0)
change = base_price * change_pct / 100
return {
"price": base_price + change,
"change": change,
"change_percent": change_pct,
"volume": random.randint(100000000, 500000000),
"amount": random.randint(10000000000, 50000000000),
}
def _fetch_updown_stats(self) -> Dict[str, Any]:
"""Fetch up/down statistics"""
try:
from data_provider.akshare_fetcher import AkshareFetcher
fetcher = AkshareFetcher()
stats = fetcher.get_market_updown_stats()
if stats:
return stats
except Exception as e:
logger.warning(f"Failed to fetch updown stats: {e}")
return self._generate_mock_updown_stats()
def _generate_mock_updown_stats(self) -> Dict[str, Any]:
"""Generate mock up/down stats"""
total = 5000
up = random.randint(1500, 3500)
down = total - up - random.randint(100, 300)
flat = total - up - down
return {
"up_count": up,
"down_count": down,
"flat_count": flat,
"limit_up_count": random.randint(10, 50),
"limit_down_count": random.randint(1, 9),
"total_count": total,
}
def _fetch_price_distribution(self) -> List[Dict[str, Any]]:
"""Fetch price distribution"""
ranges = [
("<10元", 0, 10),
("10-20元", 10, 20),
("20-50元", 20, 50),
("50-100元", 50, 100),
(">100元", 100, 10000),
]
total = 5000
distribution = []
remaining = total
for i, (label, low, high) in enumerate(ranges):
if i == len(ranges) - 1:
count = remaining
else:
count = random.randint(500, 1500)
remaining -= count
percent = round(count / total * 100, 1)
distribution.append({
"range_label": label,
"count": count,
"percent": percent
})
return distribution
def _fetch_sentiment(self) -> List[Dict[str, Any]]:
"""Fetch sentiment indicators"""
return [
{
"name": "市场宽度",
"value": random.uniform(40, 60),
"change": random.uniform(-5, 5),
"level": "medium",
"description": "上涨股票占比"
},
{
"name": "量比指标",
"value": random.uniform(0.8, 1.5),
"change": random.uniform(-0.2, 0.2),
"level": "medium",
"description": "今日成交量/5日平均成交量"
},
{
"name": "涨停数",
"value": random.randint(20, 80),
"change": random.randint(-10, 10),
"level": "medium",
"description": "涨停股票数量"
},
]
def _generate_mock_sector_momentum(self) -> List[Dict[str, Any]]:
"""Generate mock sector momentum data"""
sectors = []
for code, name in self.SECTOR_NAMES.items():
n = random.randint(0, 20)
N = self.SECTOR_STOCK_COUNT.get(code, 100)
momentum_value = (n ** 2) / N if N > 0 else 0
# Calculate rank change (mock)
rank = random.randint(1, len(self.SECTOR_NAMES))
rank_change = random.randint(-5, 5)
sectors.append({
"code": code,
"name": name,
"momentum_value": round(momentum_value, 4),
"momentum_value_change": round(random.uniform(-0.5, 1.0), 4),
"momentum_score": round(momentum_value * 100, 2),
"change_percent": round(random.uniform(-3, 8), 2),
"turnover_rate": round(random.uniform(1, 10), 2),
"leading_stock": f"{random.randint(30, 605000):06d}",
"leading_stock_name": f"龙头股{random.randint(1, 100)}",
"rank": rank,
"rank_change": rank_change,
"n": n,
"N": N,
"stocks": []
})
# Sort by momentum value
sectors.sort(key=lambda x: x.get("momentum_value", 0), reverse=True)
# Update ranks based on sorted order
for i, sector in enumerate(sectors):
sector["rank"] = i + 1
return sectors
def _fetch_sector_momentum(self) -> List[Dict[str, Any]]:
"""Fetch sector momentum data"""
return self._generate_mock_sector_momentum()
def _fetch_stock_momentum(self) -> List[Dict[str, Any]]:
"""Fetch stock momentum recommendations"""
stocks = []
for i in range(30):
code = f"{random.randint(30, 605000):06d}"
momentum_score = random.uniform(50, 95)
change_pct = random.uniform(-3, 8)
recommendation = "buy" if momentum_score > 80 else "watch" if momentum_score > 70 else "hold"
stocks.append({
"code": code,
"name": f"股票{code}",
"momentum_score": round(momentum_score, 2),
"change_percent": round(change_pct, 2),
"sector": random.choice(list(self.SECTOR_NAMES.values())),
"recommendation": recommendation,
})
stocks.sort(key=lambda x: x.get("momentum_score", 0), reverse=True)
return stocks
def _fetch_new_high_low(self, type: str, days: int) -> List[Dict[str, Any]]:
"""Fetch new high/low stocks"""
stocks = []
for i in range(30):
code = f"{random.randint(30, 605000):06d}"
price = random.uniform(10, 100)
change_pct = random.uniform(1, 10) if type == "high" else random.uniform(-1, -10)
stocks.append({
"code": code,
"name": f"股票{code}",
"price": round(price, 2),
"change_percent": round(change_pct, 2),
"days_to_high": random.randint(1, days) if type == "high" else None,
"days_to_low": random.randint(1, days) if type == "low" else None,
"high_date": datetime.now().strftime("%Y-%m-%d") if type == "high" else None,
"low_date": datetime.now().strftime("%Y-%m-%d") if type == "low" else None,
})
stocks.sort(key=lambda x: abs(x.get("change_percent", 0)), reverse=True)
return stocks
def _fetch_kline_data(self, stock_code: str, days: int) -> List[Dict[str, Any]]:
"""Fetch K-line data for stock"""
try:
from data_provider.base import DataFetcherManager
manager = DataFetcherManager()
df, source = manager.get_daily_data(stock_code, days=days)
if df is not None and not df.empty:
kline = []
for _, row in df.iterrows():
date_val = row.get("date")
if hasattr(date_val, "strftime"):
date_str = date_val.strftime("%Y-%m-%d")
else:
date_str = str(date_val)
kline.append({
"date": date_str,
"open": float(row.get("open", 0)),
"close": float(row.get("close", 0)),
"high": float(row.get("high", 0)),
"low": float(row.get("low", 0)),
"volume": float(row.get("volume", 0) or 0),
})
return kline
except Exception as e:
logger.warning(f"Failed to fetch kline for {stock_code}: {e}")
return self._generate_mock_kline(days)
def _fetch_sector_kline_data(self, sector_code: str, days: int) -> List[Dict[str, Any]]:
"""Fetch K-line data for sector"""
return self._generate_mock_kline(days)
def _generate_mock_kline(self, days: int) -> List[Dict[str, Any]]:
"""Generate mock K-line data"""
kline = []
base_price = random.uniform(10, 100)
for i in range(days):
date = datetime.now() - timedelta(days=days - i - 1)
change = random.uniform(-0.05, 0.05)
open_price = base_price
close_price = base_price * (1 + change)
high_price = max(open_price, close_price) * (1 + random.uniform(0, 0.02))
low_price = min(open_price, close_price) * (1 - random.uniform(0, 0.02))
kline.append({
"date": date.strftime("%Y-%m-%d"),
"open": round(open_price, 2),
"close": round(close_price, 2),
"high": round(high_price, 2),
"low": round(low_price, 2),
"volume": random.randint(100000, 10000000),
})
base_price = close_price
return kline
def _get_stock_name(self, stock_code: str) -> Optional[str]:
"""Get stock name"""
try:
from data_provider.base import DataFetcherManager
manager = DataFetcherManager()
return manager.get_stock_name(stock_code)
except Exception as e:
logger.warning(f"Failed to get stock name for {stock_code}: {e}")
return f"股票{stock_code}"
def _get_from_cache(self, key: str) -> Optional[Any]:
"""Get data from cache"""
if key in self._cache:
timestamp = self._cache_time.get(key, 0)
if time.time() - timestamp < self.CACHE_EXPIRY:
return self._cache[key]
else:
# Cache expired
del self._cache[key]
del self._cache_time[key]
return None
def _set_to_cache(self, key: str, value: Any) -> None:
"""Set data to cache"""
self._cache[key] = value
if self._cache_time is None:
self._cache_time = {}
self._cache_time[key] = time.time()
import time
Loading…
Cancel
Save