From 639753f593981ee0a63c7054815cc7ea06e9811d Mon Sep 17 00:00:00 2001 From: Lxy Date: Sun, 12 Apr 2026 13:51:36 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A2=9E=E5=8A=A0=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E7=BC=93=E5=AD=98=E6=94=B9=E9=80=A0=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/v1/endpoints/market.py | 218 +++++ api/v1/endpoints/sectors.py | 100 ++ api/v1/endpoints/stocks.py | 222 ++++- api/v1/router.py | 23 +- api/v1/schemas/market.py | 125 +++ apps/dsa-web/package-lock.json | 26 + apps/dsa-web/package.json | 1 + apps/dsa-web/src/App.tsx | 189 ++-- apps/dsa-web/src/api/market.ts | 80 ++ .../src/components/charts/KLineChartModal.tsx | 222 +++++ apps/dsa-web/src/pages/HomePage.tsx | 335 +++++-- apps/dsa-web/src/pages/TrendPage.tsx | 302 ++++++ apps/dsa-web/src/types/market.ts | 73 ++ data_provider/amazingdata_fetcher.py | 443 +++++++++ data_provider/base.py | 3 + docs/cache_update_strategy.md | 370 +++++++ docs/customApi/API.md | 913 ++++++++++++++++++ docs/database_design.md | 191 ++++ docs/implementation_plan.md | 431 +++++++++ docs/performance_optimization.md | 519 ++++++++++ docs/redis_cache_design.md | 348 +++++++ scripts/create_tables.sql | 118 +++ src/config.py | 12 + src/services/market_service.py | 912 +++++++++++++++++ 24 files changed, 5953 insertions(+), 223 deletions(-) create mode 100644 api/v1/endpoints/market.py create mode 100644 api/v1/endpoints/sectors.py create mode 100644 api/v1/schemas/market.py create mode 100644 apps/dsa-web/src/api/market.ts create mode 100644 apps/dsa-web/src/components/charts/KLineChartModal.tsx create mode 100644 apps/dsa-web/src/pages/TrendPage.tsx create mode 100644 apps/dsa-web/src/types/market.ts create mode 100644 data_provider/amazingdata_fetcher.py create mode 100644 docs/cache_update_strategy.md create mode 100644 docs/customApi/API.md create mode 100644 docs/database_design.md create mode 100644 docs/implementation_plan.md create mode 100644 docs/performance_optimization.md create mode 100644 docs/redis_cache_design.md create mode 100644 scripts/create_tables.sql create mode 100644 src/services/market_service.py diff --git a/api/v1/endpoints/market.py b/api/v1/endpoints/market.py new file mode 100644 index 0000000..bba223d --- /dev/null +++ b/api/v1/endpoints/market.py @@ -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 [] \ No newline at end of file diff --git a/api/v1/endpoints/sectors.py b/api/v1/endpoints/sectors.py new file mode 100644 index 0000000..fddb956 --- /dev/null +++ b/api/v1/endpoints/sectors.py @@ -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 + ) \ No newline at end of file diff --git a/api/v1/endpoints/stocks.py b/api/v1/endpoints/stocks.py index 5b78953..c15322e 100644 --- a/api/v1/endpoints/stocks.py +++ b/api/v1/endpoints/stocks.py @@ -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: 股票代码(如 600519、00700、AAPL) + stock_code: Stock code (e.g., 600519, 00700, AAPL) Returns: - StockQuote: 实时行情数据 - - Raises: - HTTPException: 404 - 股票不存在 + StockQuote: Real-time quote data """ try: - service = StockService() - - # 使用 def 而非 async def,FastAPI 自动在线程池中执行 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 def,FastAPI 自动在线程池中执行 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 + ) \ No newline at end of file diff --git a/api/v1/router.py b/api/v1/router.py index b311740..3454b7a 100644 --- a/api/v1/router.py +++ b/api/v1/router.py @@ -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"] +) \ No newline at end of file diff --git a/api/v1/schemas/market.py b/api/v1/schemas/market.py new file mode 100644 index 0000000..f856bef --- /dev/null +++ b/api/v1/schemas/market.py @@ -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") \ No newline at end of file diff --git a/apps/dsa-web/package-lock.json b/apps/dsa-web/package-lock.json index dd788b4..f70e324 100644 --- a/apps/dsa-web/package-lock.json +++ b/apps/dsa-web/package-lock.json @@ -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", diff --git a/apps/dsa-web/package.json b/apps/dsa-web/package.json index 465ffe3..861a26d 100644 --- a/apps/dsa-web/package.json +++ b/apps/dsa-web/package.json @@ -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", diff --git a/apps/dsa-web/src/App.tsx b/apps/dsa-web/src/App.tsx index d5085f9..ecf5eed 100644 --- a/apps/dsa-web/src/App.tsx +++ b/apps/dsa-web/src/App.tsx @@ -1,117 +1,128 @@ 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 }) => ( + + + ); -const BacktestIcon: React.FC<{ active?: boolean }> = ({active}) => ( - - - +const TrendIcon: React.FC<{ active?: boolean }> = ({ active }) => ( + + + ); -const SettingsIcon: React.FC<{ active?: boolean }> = ({active}) => ( - - - - +const BacktestIcon: React.FC<{ active?: boolean }> = ({ active }) => ( + + + +); + +const SettingsIcon: React.FC<{ active?: boolean }> = ({ active }) => ( + + + + ); type DockItem = { - key: string; - label: string; - to: string; - icon: React.FC<{ active?: boolean }>; + key: string; + label: string; + to: string; + icon: React.FC<{ active?: boolean }>; }; const NAV_ITEMS: DockItem[] = [ - { - key: 'home', - label: '首页', - to: '/', - icon: HomeIcon, - }, - { - key: 'backtest', - label: '回测', - to: '/backtest', - icon: BacktestIcon, - }, - { - key: 'settings', - label: '设置', - to: '/settings', - icon: SettingsIcon, - }, + { + key: 'home', + label: '首页', + to: '/', + icon: HomeIcon, + }, + { + key: 'trend', + label: '趋势', + to: '/trend', + icon: TrendIcon, + }, + { + key: 'backtest', + label: '回测', + to: '/backtest', + icon: BacktestIcon, + }, + { + key: 'settings', + label: '设置', + to: '/settings', + icon: SettingsIcon, + }, ]; -// Dock 导航栏 const DockNav: React.FC = () => { - return ( - + ); }; const App: React.FC = () => { - return ( - -
- {/* Dock 导航 */} - + return ( + +
+ - {/* 主内容区 */} -
- - }/> - }/> - }/> - }/> - -
-
-
- ); +
+ + } /> + } /> + } /> + } /> + } /> + +
+
+
+ ); }; -export default App; +export default App; \ No newline at end of file diff --git a/apps/dsa-web/src/api/market.ts b/apps/dsa-web/src/api/market.ts new file mode 100644 index 0000000..37096f1 --- /dev/null +++ b/apps/dsa-web/src/api/market.ts @@ -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 => { + const response = await apiClient.get('/market/indices'); + return response.data; + }, + + getUpDownStats: async (): Promise => { + 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 => { + 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 => { + 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 => { + const response = await apiClient.get('/stocks/new-high', { + params: { days, limit }, + }); + return response.data; + }, + + getNewLowStocks: async (days: number = 20, limit: number = 20): Promise => { + const response = await apiClient.get('/stocks/new-low', { + params: { days, limit }, + }); + return response.data; + }, + + getStockKline: async (stockCode: string, days: number = 60): Promise => { + const response = await apiClient.get(`/stocks/${stockCode}/kline`, { + params: { days }, + }); + return response.data; + }, +}; \ No newline at end of file diff --git a/apps/dsa-web/src/components/charts/KLineChartModal.tsx b/apps/dsa-web/src/components/charts/KLineChartModal.tsx new file mode 100644 index 0000000..9177b9a --- /dev/null +++ b/apps/dsa-web/src/components/charts/KLineChartModal.tsx @@ -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 = ({ + title, + code, + type, + data, + isLoading, + onClose, +}) => { + const chartRef = useRef(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 ( +
+
+
+
+

{title}

+

{code} · {type === 'stock' ? '个股' : '板块'} K线

+
+ +
+ +
+ {isLoading ? ( +
+
+

加载K线数据...

+
+ ) : data && data.values.length > 0 ? ( +
+ ) : ( +
+

暂无K线数据

+
+ )} +
+
+
+ ); +}; + +export default KLineChartModal; \ No newline at end of file diff --git a/apps/dsa-web/src/pages/HomePage.tsx b/apps/dsa-web/src/pages/HomePage.tsx index 514b3d0..a7aa37f 100644 --- a/apps/dsa-web/src/pages/HomePage.tsx +++ b/apps/dsa-web/src/pages/HomePage.tsx @@ -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(); -// 历史列表状态 const [historyItems, setHistoryItems] = useState([]); 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(null); const [isLoadingReport, setIsLoadingReport] = useState(false); - // 任务队列状态 const [activeTasks, setActiveTasks] = useState([]); const [duplicateError, setDuplicateError] = useState(null); - // 用于跟踪当前分析请求,避免竞态条件 const analysisRequestIdRef = useRef(0); - // 更新任务列表中的任务 + const [indices, setIndices] = useState([]); + const [updownStats, setUpdownStats] = useState(null); + const [sectors, setSectors] = useState([]); + const [stockRecommendations, setStockRecommendations] = useState([]); + const [newHigh, setNewHigh] = useState([]); + const [newLow, setNewLow] = useState([]); + + const [sentiment, setSentiment] = useState([]); + 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 (
- {/* 顶部输入栏 */}
@@ -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,64 +278,216 @@ const HomePage: React.FC = () => { - 分析中 + Analyzing ) : ( - '分析' + 'Analyze' )}
- {/* 主内容区 */} -
-{/* 左侧:任务面板 + 历史列表 */} -
- {/* 任务面板 */} - - - {/* 历史列表 */} - -
+
+ {isLoadingMarket ? ( +
+
+

Loading market data...

+
+ ) : ( +
+
+ {indices.map((idx) => ( + +
{idx.name}
+
+ {idx.price.toFixed(2)} +
+
+ {formatPercent(idx.change_percent || 0)} +
+
+ ))} +
+ + {updownStats && ( + +
+ Market Breadth +
+
+
+
{updownStats.up_count}
+
Up
+
+
+
{updownStats.down_count}
+
Down
+
+
+
{updownStats.flat_count}
+
Flat
+
+
+
{updownStats.limit_up_count}
+
Limit Up
+
+
+
{updownStats.limit_down_count}
+
Limit Down
+
+
+
+ )} + +
+ +
+ Sector Momentum TOP5 +
+
+ {sectors.map((sector, idx) => ( +
+
+ {idx + 1} + {sector.name} +
+
+ {sector.momentum_score.toFixed(1)} + + {formatPercent(sector.change_percent)} + +
+
+ ))} +
+
+ + +
+ Stock Recommendations +
+
+ {stockRecommendations.slice(0, 10).map((stock, idx) => ( +
+
+ {idx + 1} +
+ {stock.name} + {stock.code} +
+
+
+ + {stock.recommendation?.toUpperCase() || 'HOLD'} + + + {formatPercent(stock.change_percent)} + +
+
+ ))} +
+
+
- {/* 右侧报告详情 */} -
- {isLoadingReport ? ( -
-
-

加载报告中...

+
+ +
+ New High (20d) +
+
+ {newHigh.slice(0, 8).map((stock) => ( +
+
+ {stock.name} + {stock.code} +
+ {formatPercent(stock.change_percent)} +
+ ))} +
+
+ + +
+ New Low (20d) +
+
+ {newLow.slice(0, 8).map((stock) => ( +
+
+ {stock.name} + {stock.code} +
+ {formatPercent(stock.change_percent)} +
+ ))} +
+
- ) : selectedReport ? ( -
- {/* 报告内容 */} - + +
+ {sentiment.map((ind) => ( + +
{ind.name}
+
+ {ind.value.toFixed(ind.value < 10 ? 2 : 0)} +
+ {ind.change !== undefined && ( +
+ {formatPercent(ind.change)} +
+ )} +
{ind.description}
+
+ ))}
- ) : ( -
-
- - - + +
+
+ +
-

开始分析

-

- 输入股票代码进行分析,或从左侧选择历史报告查看 -

+ +
+ {isLoadingReport ? ( +
+
+

Loading report...

+
+ ) : selectedReport ? ( +
+ +
+ ) : ( +
+
+ + + +
+

Start Analysis

+

+ Enter stock code to analyze, or select from history +

+
+ )} +
- )} -
+
+ )}
); }; -export default HomePage; +export default HomePage; \ No newline at end of file diff --git a/apps/dsa-web/src/pages/TrendPage.tsx b/apps/dsa-web/src/pages/TrendPage.tsx new file mode 100644 index 0000000..9fbc511 --- /dev/null +++ b/apps/dsa-web/src/pages/TrendPage.tsx @@ -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 BUY; + case 'watch': + return WATCH; + case 'hold': + return HOLD; + default: + return {rec}; + } +} + +const TrendPage: React.FC = () => { + const [sectors, setSectors] = useState([]); + const [stocks, setStocks] = useState([]); + const [newHigh, setNewHigh] = useState([]); + const [newLow, setNewLow] = useState([]); + const [updownStats, setUpdownStats] = useState(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(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 ( +
+
+

趋势分析

+

动量板块、个股推荐、新高新低分布

+
+ +
+ {isLoading ? ( +
+
+

加载中...

+
+ ) : ( +
+ +
+ 动量板块 TOP5 +
+
+ {sectors.map((sector, idx) => ( +
handleItemClick('sector', sector.code, sector.name)} + > +
+ {sector.rank || idx + 1} + {sector.name} + {sector.rank_change !== undefined && ( + + {getRankChangeIcon(sector.rank_change)} {Math.abs(sector.rank_change)} + + )} +
+
+ {formatValue(sector.momentum_value || 0)} + {sector.momentum_value_change !== undefined && ( + + {formatValue(sector.momentum_value_change)} + + )} + + {formatPercent(sector.change_percent)} + +
+
+ ))} +
+
+ + +
+ 动量个股推荐 +
+
+ {stocks.map((stock, idx) => ( +
handleItemClick('stock', stock.code, stock.name)} + > +
+ {idx + 1} +
+ {stock.name} + {stock.code} +
+
+
+ {recommendationBadge(stock.recommendation)} + + {formatPercent(stock.change_percent)} + +
+
+ ))} +
+
+ + +
+ 新高个股 + (20日内) +
+
+ {newHigh.slice(0, 10).map((stock) => ( +
handleItemClick('stock', stock.code, stock.name)} + > +
+ {stock.name} + {stock.code} +
+
+ {stock.price.toFixed(2)} + {formatPercent(stock.change_percent)} +
+
+ ))} +
+
+ + +
+ 新低个股 + (20日内) +
+
+ {newLow.slice(0, 10).map((stock) => ( +
handleItemClick('stock', stock.code, stock.name)} + > +
+ {stock.name} + {stock.code} +
+
+ {stock.price.toFixed(2)} + {formatPercent(stock.change_percent)} +
+
+ ))} +
+
+ + {updownStats && ( + +
+ 涨跌分布 +
+
+
+
{updownStats.up_count}
+
上涨
+
+
+
{updownStats.down_count}
+
下跌
+
+
+
{updownStats.flat_count}
+
平盘
+
+
+
{updownStats.limit_up_count}
+
涨停
+
+
+
{updownStats.limit_down_count}
+
跌停
+
+
+
+ )} +
+ )} +
+ + {chartModal?.open && ( + + )} +
+ ); +}; + +export default TrendPage; \ No newline at end of file diff --git a/apps/dsa-web/src/types/market.ts b/apps/dsa-web/src/types/market.ts new file mode 100644 index 0000000..3cb545a --- /dev/null +++ b/apps/dsa-web/src/types/market.ts @@ -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; +} \ No newline at end of file diff --git a/data_provider/amazingdata_fetcher.py b/data_provider/amazingdata_fetcher.py new file mode 100644 index 0000000..6948e5e --- /dev/null +++ b/data_provider/amazingdata_fetcher.py @@ -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 \ No newline at end of file diff --git a/data_provider/base.py b/data_provider/base.py index 905fad8..6b3f7f0 100644 --- a/data_provider/base.py +++ b/data_provider/base.py @@ -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, diff --git a/docs/cache_update_strategy.md b/docs/cache_update_strategy.md new file mode 100644 index 0000000..a937e74 --- /dev/null +++ b/docs/cache_update_strategy.md @@ -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中 + - 高频访问 +- **冷数据**: + - 存储在数据库中 + - 低频访问 +- **预热数据**: + - 提前计算并缓存 + - 减少请求时计算开销 + +## 九、总结 + +缓存更新策略是保证股票分析系统性能和数据一致性的关键。通过合理的更新频率、流程和监控,可以确保缓存数据的及时性和准确性,同时提高系统的响应速度和可靠性。 + +本策略采用了多种技术手段,包括定时更新、按需更新、缓存预热、一致性保障等,以应对不同类型数据的更新需求。同时,通过监控和异常处理,确保系统在面对各种异常情况时能够稳定运行。 + +随着系统的发展和数据量的增长,缓存更新策略也需要不断优化和调整,以适应新的业务需求和技术挑战。 \ No newline at end of file diff --git a/docs/customApi/API.md b/docs/customApi/API.md new file mode 100644 index 0000000..398b949 --- /dev/null +++ b/docs/customApi/API.md @@ -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) +``` + +--- + +## 十一、联系与支持 + +如有问题,请联系技术支持团队。 \ No newline at end of file diff --git a/docs/database_design.md b/docs/database_design.md new file mode 100644 index 0000000..7ef19bd --- /dev/null +++ b/docs/database_design.md @@ -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:主从复制 + 哨兵模式 + - 定期备份:每日全量备份,每小时增量备份 diff --git a/docs/implementation_plan.md b/docs/implementation_plan.md new file mode 100644 index 0000000..a83b60c --- /dev/null +++ b/docs/implementation_plan.md @@ -0,0 +1,431 @@ +# 实现步骤和迁移计划 + +## 一、概述 + +本文档详细说明股票分析系统引入数据库和Redis缓存的实现步骤和迁移计划,包括基础设施搭建、代码修改、数据迁移、测试验证等环节,确保系统平稳过渡到新的架构。 + +## 二、实施阶段 + +### 阶段一:基础设施搭建(1-2周) + +#### 1. 环境准备 + +- **硬件准备**: + - 数据库服务器:4核8G内存,500G存储空间 + - Redis服务器:4核8G内存,100G存储空间 + - 网络配置:确保服务器间网络互通 + +- **软件安装**: + - 数据库:PostgreSQL 14+ + - Redis:Redis 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缓存的全过程,包括基础设施搭建、代码修改、数据迁移、测试验证等环节。通过分阶段实施和灰度发布策略,可以确保系统平稳过渡到新的架构,同时降低迁移风险。 + +实施过程中,需要密切关注系统性能和数据一致性,及时处理出现的问题,确保系统的稳定性和可靠性。通过本计划的实施,可以显著提高系统性能,提升用户体验,为股票分析系统的长期发展奠定基础。 \ No newline at end of file diff --git a/docs/performance_optimization.md b/docs/performance_optimization.md new file mode 100644 index 0000000..a301bd3 --- /dev/null +++ b/docs/performance_optimization.md @@ -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. 连接池优化 + +- **配置优化**: + - 合理设置连接池大小 + - 调整连接超时时间 + - 监控连接使用情况 + +- **技术选型**: + - PostgreSQL:PgBouncer + - MySQL:ProxySQL + +## 四、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、前端等多个维度进行全面优化。通过本方案的实施,可以显著提高系统性能,提升用户体验,为股票分析系统的稳定运行和未来发展奠定基础。 + +同时,性能优化需要结合实际情况,根据系统的特点和业务需求,制定合理的优化策略。通过持续监控和调优,不断提升系统性能,以适应不断变化的业务需求和技术挑战。 \ No newline at end of file diff --git a/docs/redis_cache_design.md b/docs/redis_cache_design.md new file mode 100644 index 0000000..5b8f863 --- /dev/null +++ b/docs/redis_cache_design.md @@ -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缓存架构设计采用多种数据结构和缓存策略,为股票分析系统提供高性能、高可靠性的数据访问层。通过合理的缓存键设计、数据结构选择和缓存策略,可以显著减少对数据源和数据库的访问,提高系统响应速度,同时保证数据的一致性和可靠性。 + +该设计不仅满足当前系统的需求,还为未来的扩展和优化预留了空间,能够应对数据量增长和业务需求变化的挑战。 \ No newline at end of file diff --git a/scripts/create_tables.sql b/scripts/create_tables.sql new file mode 100644 index 0000000..d8467ac --- /dev/null +++ b/scripts/create_tables.sql @@ -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); diff --git a/src/config.py b/src/config.py index 7d1506a..f23637c 100644 --- a/src/config.py +++ b/src/config.py @@ -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'), diff --git a/src/services/market_service.py b/src/services/market_service.py new file mode 100644 index 0000000..a1a9a8f --- /dev/null +++ b/src/services/market_service.py @@ -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²) / 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 \ No newline at end of file