parent
e26b7960fb
commit
639753f593
@ -0,0 +1,218 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
===================================
|
||||||
|
Market data endpoints
|
||||||
|
===================================
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
1. Provide market indices data
|
||||||
|
2. Provide sector momentum data
|
||||||
|
3. Provide stock momentum recommendations
|
||||||
|
4. Provide new high/low stocks
|
||||||
|
5. Provide price distribution
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query, Depends
|
||||||
|
|
||||||
|
from api.v1.schemas.market import (
|
||||||
|
MarketIndex,
|
||||||
|
UpDownStats,
|
||||||
|
SectorMomentum,
|
||||||
|
StockMomentum,
|
||||||
|
NewHighLowStock,
|
||||||
|
PriceDistribution,
|
||||||
|
SentimentIndicator,
|
||||||
|
MarketOverview,
|
||||||
|
SectorListResponse,
|
||||||
|
StockMomentumResponse,
|
||||||
|
NewHighLowResponse,
|
||||||
|
PriceDistributionResponse,
|
||||||
|
KLineChartResponse,
|
||||||
|
)
|
||||||
|
from api.v1.schemas.common import ErrorResponse
|
||||||
|
from src.services.market_service import MarketService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def get_market_service() -> MarketService:
|
||||||
|
"""Dependency injection for MarketService"""
|
||||||
|
return MarketService()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/indices",
|
||||||
|
response_model=List[MarketIndex],
|
||||||
|
responses={
|
||||||
|
200: {"description": "Market indices data"},
|
||||||
|
500: {"description": "Server error", "model": ErrorResponse},
|
||||||
|
},
|
||||||
|
summary="Get market indices",
|
||||||
|
description="Get major market indices (Shanghai, Shenzhen, ChiNext, STAR50)"
|
||||||
|
)
|
||||||
|
def get_indices(
|
||||||
|
service: MarketService = Depends(get_market_service)
|
||||||
|
) -> List[MarketIndex]:
|
||||||
|
"""
|
||||||
|
Get market indices data
|
||||||
|
|
||||||
|
Returns major market indices including:
|
||||||
|
- Shanghai Composite Index (000001.SH)
|
||||||
|
- Shenzhen Component Index (399001.SZ)
|
||||||
|
- ChiNext Index (399006.SZ)
|
||||||
|
- STAR50 Index (000698.SH)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
indices = service.get_indices()
|
||||||
|
return indices
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get market indices: {e}", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/updown-stats",
|
||||||
|
response_model=UpDownStats,
|
||||||
|
responses={
|
||||||
|
200: {"description": "Up/down statistics"},
|
||||||
|
500: {"description": "Server error", "model": ErrorResponse},
|
||||||
|
},
|
||||||
|
summary="Get up/down statistics",
|
||||||
|
description="Get market up/down statistics including limit up/down counts"
|
||||||
|
)
|
||||||
|
def get_updown_stats(
|
||||||
|
service: MarketService = Depends(get_market_service)
|
||||||
|
) -> UpDownStats:
|
||||||
|
"""
|
||||||
|
Get up/down statistics
|
||||||
|
|
||||||
|
Returns statistics about:
|
||||||
|
- Number of rising stocks
|
||||||
|
- Number of falling stocks
|
||||||
|
- Number of flat stocks
|
||||||
|
- Limit up/down counts
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stats = service.get_updown_stats()
|
||||||
|
return stats
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get updown stats: {e}", exc_info=True)
|
||||||
|
return UpDownStats(
|
||||||
|
up_count=0,
|
||||||
|
down_count=0,
|
||||||
|
flat_count=0,
|
||||||
|
limit_up_count=0,
|
||||||
|
limit_down_count=0,
|
||||||
|
total_count=0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/price-distribution",
|
||||||
|
response_model=PriceDistributionResponse,
|
||||||
|
responses={
|
||||||
|
200: {"description": "Price distribution"},
|
||||||
|
500: {"description": "Server error", "model": ErrorResponse},
|
||||||
|
},
|
||||||
|
summary="Get price distribution",
|
||||||
|
description="Get stock price distribution across different price ranges"
|
||||||
|
)
|
||||||
|
def get_price_distribution(
|
||||||
|
service: MarketService = Depends(get_market_service)
|
||||||
|
) -> PriceDistributionResponse:
|
||||||
|
"""
|
||||||
|
Get price distribution
|
||||||
|
|
||||||
|
Returns distribution of stocks across price ranges:
|
||||||
|
- Under 10 yuan
|
||||||
|
- 10-20 yuan
|
||||||
|
- 20-50 yuan
|
||||||
|
- 50-100 yuan
|
||||||
|
- Over 100 yuan
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
distribution = service.get_price_distribution()
|
||||||
|
return distribution
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get price distribution: {e}", exc_info=True)
|
||||||
|
return PriceDistributionResponse(items=[], total_count=0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/overview",
|
||||||
|
response_model=MarketOverview,
|
||||||
|
responses={
|
||||||
|
200: {"description": "Market overview"},
|
||||||
|
500: {"description": "Server error", "model": ErrorResponse},
|
||||||
|
},
|
||||||
|
summary="Get market overview",
|
||||||
|
description="Get comprehensive market overview including indices, stats, and sentiment"
|
||||||
|
)
|
||||||
|
def get_market_overview(
|
||||||
|
service: MarketService = Depends(get_market_service)
|
||||||
|
) -> MarketOverview:
|
||||||
|
"""
|
||||||
|
Get market overview
|
||||||
|
|
||||||
|
Returns comprehensive market data including:
|
||||||
|
- Major indices
|
||||||
|
- Up/down statistics
|
||||||
|
- Sentiment indicators
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
indices = service.get_indices()
|
||||||
|
stats = service.get_updown_stats()
|
||||||
|
sentiment = service.get_sentiment_indicators()
|
||||||
|
|
||||||
|
return MarketOverview(
|
||||||
|
indices=indices,
|
||||||
|
updown_stats=stats,
|
||||||
|
sentiment=sentiment,
|
||||||
|
update_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get market overview: {e}", exc_info=True)
|
||||||
|
return MarketOverview(
|
||||||
|
indices=[],
|
||||||
|
updown_stats=UpDownStats(
|
||||||
|
up_count=0, down_count=0, flat_count=0,
|
||||||
|
limit_up_count=0, limit_down_count=0, total_count=0
|
||||||
|
),
|
||||||
|
sentiment=[],
|
||||||
|
update_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sentiment",
|
||||||
|
response_model=List[SentimentIndicator],
|
||||||
|
responses={
|
||||||
|
200: {"description": "Sentiment indicators"},
|
||||||
|
500: {"description": "Server error", "model": ErrorResponse},
|
||||||
|
},
|
||||||
|
summary="Get sentiment indicators",
|
||||||
|
description="Get market sentiment indicators"
|
||||||
|
)
|
||||||
|
def get_sentiment(
|
||||||
|
service: MarketService = Depends(get_market_service)
|
||||||
|
) -> List[SentimentIndicator]:
|
||||||
|
"""
|
||||||
|
Get sentiment indicators
|
||||||
|
|
||||||
|
Returns various sentiment data including:
|
||||||
|
- Fear & Greed Index
|
||||||
|
- Market breadth
|
||||||
|
- Volatility index
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sentiment = service.get_sentiment_indicators()
|
||||||
|
return sentiment
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get sentiment: {e}", exc_info=True)
|
||||||
|
return []
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
===================================
|
||||||
|
Sector data endpoints
|
||||||
|
===================================
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
1. Provide sector momentum data
|
||||||
|
2. Provide sector ranking
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query, Depends
|
||||||
|
|
||||||
|
from api.v1.schemas.market import (
|
||||||
|
SectorMomentum,
|
||||||
|
SectorListResponse,
|
||||||
|
KLineChartResponse,
|
||||||
|
)
|
||||||
|
from api.v1.schemas.common import ErrorResponse
|
||||||
|
from src.services.market_service import MarketService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def get_market_service() -> MarketService:
|
||||||
|
"""Dependency injection for MarketService"""
|
||||||
|
return MarketService()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=SectorListResponse,
|
||||||
|
responses={
|
||||||
|
200: {"description": "Sector momentum list"},
|
||||||
|
500: {"description": "Server error", "model": ErrorResponse},
|
||||||
|
},
|
||||||
|
summary="Get sector momentum list",
|
||||||
|
description="Get sector momentum ranking with sorting options"
|
||||||
|
)
|
||||||
|
def get_sectors(
|
||||||
|
sort: str = Query("momentumValue", description="Sort field (momentumValue, momentumScore, changePercent, rankChange)"),
|
||||||
|
order: str = Query("desc", description="Sort order (asc, desc)"),
|
||||||
|
limit: int = Query(20, ge=1, le=100, description="Limit count"),
|
||||||
|
period: int = Query(5, ge=1, le=60, description="Period for calculating momentum (days)"),
|
||||||
|
service: MarketService = Depends(get_market_service)
|
||||||
|
) -> SectorListResponse:
|
||||||
|
"""
|
||||||
|
Get sector momentum list
|
||||||
|
|
||||||
|
Returns sector momentum data sorted by specified field
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sectors = service.get_sector_momentum(sort=sort, order=order, limit=limit, period=period)
|
||||||
|
return SectorListResponse(
|
||||||
|
items=sectors,
|
||||||
|
total=len(sectors)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get sector momentum: {e}", exc_info=True)
|
||||||
|
return SectorListResponse(items=[], total=0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{sector_code}/kline",
|
||||||
|
response_model=KLineChartResponse,
|
||||||
|
responses={
|
||||||
|
200: {"description": "Sector K-line chart data"},
|
||||||
|
404: {"description": "Sector not found", "model": ErrorResponse},
|
||||||
|
500: {"description": "Server error", "model": ErrorResponse},
|
||||||
|
},
|
||||||
|
summary="Get sector K-line chart",
|
||||||
|
description="Get sector K-line chart data for ECharts"
|
||||||
|
)
|
||||||
|
def get_sector_kline(
|
||||||
|
sector_code: str,
|
||||||
|
days: int = Query(60, ge=1, le=365, description="Number of days"),
|
||||||
|
service: MarketService = Depends(get_market_service)
|
||||||
|
) -> KLineChartResponse:
|
||||||
|
"""
|
||||||
|
Get sector K-line chart data
|
||||||
|
|
||||||
|
Returns K-line data formatted for ECharts
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
kline = service.get_sector_kline(sector_code, days=days)
|
||||||
|
return kline
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get sector kline: {e}", exc_info=True)
|
||||||
|
return KLineChartResponse(
|
||||||
|
categoryData=[],
|
||||||
|
values=[],
|
||||||
|
volumes=[],
|
||||||
|
stock_name=None
|
||||||
|
)
|
||||||
@ -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")
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
import apiClient from './index';
|
||||||
|
import type {
|
||||||
|
MarketIndex,
|
||||||
|
UpDownStats,
|
||||||
|
SectorMomentum,
|
||||||
|
StockMomentum,
|
||||||
|
NewHighLowStock,
|
||||||
|
PriceDistribution,
|
||||||
|
SentimentIndicator,
|
||||||
|
KLineChartResponse,
|
||||||
|
} from '../types/market';
|
||||||
|
|
||||||
|
export const marketApi = {
|
||||||
|
getIndices: async (): Promise<MarketIndex[]> => {
|
||||||
|
const response = await apiClient.get('/market/indices');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getUpDownStats: async (): Promise<UpDownStats> => {
|
||||||
|
const response = await apiClient.get('/market/updown-stats');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPriceDistribution: async (): Promise<{ items: PriceDistribution[]; total_count: number }> => {
|
||||||
|
const response = await apiClient.get('/market/price-distribution');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getSentiment: async (): Promise<SentimentIndicator[]> => {
|
||||||
|
const response = await apiClient.get('/market/sentiment');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getSectorMomentum: async (
|
||||||
|
sort: string = 'momentumValue',
|
||||||
|
order: string = 'desc',
|
||||||
|
limit: number = 20,
|
||||||
|
period: number = 5
|
||||||
|
): Promise<{ items: SectorMomentum[]; total: number }> => {
|
||||||
|
const response = await apiClient.get('/sectors', {
|
||||||
|
params: { sort, order, limit, period },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getSectorKline: async (sectorCode: string, days: number = 60): Promise<KLineChartResponse> => {
|
||||||
|
const response = await apiClient.get(`/sectors/${sectorCode}/kline`, {
|
||||||
|
params: { days },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getStockMomentumRecommendation: async (limit: number = 15): Promise<{ items: StockMomentum[]; total: number }> => {
|
||||||
|
const response = await apiClient.get('/stocks/momentum-recommendation', {
|
||||||
|
params: { limit },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getNewHighStocks: async (days: number = 20, limit: number = 20): Promise<NewHighLowStock[]> => {
|
||||||
|
const response = await apiClient.get('/stocks/new-high', {
|
||||||
|
params: { days, limit },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getNewLowStocks: async (days: number = 20, limit: number = 20): Promise<NewHighLowStock[]> => {
|
||||||
|
const response = await apiClient.get('/stocks/new-low', {
|
||||||
|
params: { days, limit },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getStockKline: async (stockCode: string, days: number = 60): Promise<KLineChartResponse> => {
|
||||||
|
const response = await apiClient.get(`/stocks/${stockCode}/kline`, {
|
||||||
|
params: { days },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,222 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import type { KLineChartResponse } from '../../types/market';
|
||||||
|
|
||||||
|
interface KLineChartModalProps {
|
||||||
|
title: string;
|
||||||
|
code: string;
|
||||||
|
type: 'stock' | 'sector';
|
||||||
|
data: KLineChartResponse | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||||
|
title,
|
||||||
|
code,
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || !chartRef.current || isLoading) return;
|
||||||
|
|
||||||
|
const renderChart = async () => {
|
||||||
|
try {
|
||||||
|
const echarts = await import('echarts');
|
||||||
|
const chart = echarts.init(chartRef.current!, 'dark');
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
animation: false,
|
||||||
|
legend: {
|
||||||
|
data: [type === 'stock' ? 'K线' : '板块走势', '成交量'],
|
||||||
|
bottom: 10,
|
||||||
|
textStyle: { color: '#8b949e' },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'cross' },
|
||||||
|
backgroundColor: 'rgba(22, 27, 34, 0.9)',
|
||||||
|
borderColor: '#30363d',
|
||||||
|
textStyle: { color: '#c9d1d9' },
|
||||||
|
},
|
||||||
|
axisPointer: {
|
||||||
|
link: [{ xAxisIndex: 'all' }],
|
||||||
|
},
|
||||||
|
grid: [
|
||||||
|
{
|
||||||
|
left: '10%',
|
||||||
|
right: '8%',
|
||||||
|
top: '10%',
|
||||||
|
height: '50%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
left: '10%',
|
||||||
|
right: '8%',
|
||||||
|
top: '70%',
|
||||||
|
height: '20%',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
xAxis: [
|
||||||
|
{
|
||||||
|
type: 'category',
|
||||||
|
data: data.categoryData,
|
||||||
|
boundaryGap: false,
|
||||||
|
axisLine: { lineStyle: { color: '#30363d' } },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: { color: '#8b949e', fontSize: 10 },
|
||||||
|
splitLine: { show: false },
|
||||||
|
min: 'dataMin',
|
||||||
|
max: 'dataMax',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'category',
|
||||||
|
gridIndex: 1,
|
||||||
|
data: data.categoryData,
|
||||||
|
boundaryGap: false,
|
||||||
|
axisLine: { lineStyle: { color: '#30363d' } },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: { show: false },
|
||||||
|
splitLine: { show: false },
|
||||||
|
min: 'dataMin',
|
||||||
|
max: 'dataMax',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
scale: true,
|
||||||
|
axisLine: { lineStyle: { color: '#30363d' } },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: { color: '#8b949e', fontSize: 10 },
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: '#21262d',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scale: true,
|
||||||
|
gridIndex: 1,
|
||||||
|
splitNumber: 2,
|
||||||
|
axisLine: { lineStyle: { color: '#30363d' } },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: { color: '#8b949e', fontSize: 10 },
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: '#21262d',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dataZoom: [
|
||||||
|
{
|
||||||
|
type: 'inside',
|
||||||
|
xAxisIndex: [0, 1],
|
||||||
|
start: 50,
|
||||||
|
end: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
show: true,
|
||||||
|
xAxisIndex: [0, 1],
|
||||||
|
type: 'slider',
|
||||||
|
bottom: 10,
|
||||||
|
start: 50,
|
||||||
|
end: 100,
|
||||||
|
borderColor: '#30363d',
|
||||||
|
backgroundColor: '#161b22',
|
||||||
|
fillerColor: 'rgba(48, 54, 61, 0.4)',
|
||||||
|
handleStyle: {
|
||||||
|
color: '#58a6ff',
|
||||||
|
borderColor: '#58a6ff',
|
||||||
|
},
|
||||||
|
textStyle: { color: '#8b949e' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: type === 'stock' ? 'K线' : '板块走势',
|
||||||
|
type: 'candlestick',
|
||||||
|
data: data.values,
|
||||||
|
itemStyle: {
|
||||||
|
color: '#238636',
|
||||||
|
color0: '#da3633',
|
||||||
|
borderColor: '#238636',
|
||||||
|
borderColor0: '#da3633',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '成交量',
|
||||||
|
type: 'bar',
|
||||||
|
xAxisIndex: 1,
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: data.volumes,
|
||||||
|
itemStyle: {
|
||||||
|
color: function (params: { value: number[] }) {
|
||||||
|
const changeSign = params.value[2];
|
||||||
|
return changeSign >= 0 ? '#238636' : '#da3633';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
chart.setOption(option);
|
||||||
|
|
||||||
|
const handleResize = () => chart.resize();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
chart.dispose();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to render chart:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
renderChart();
|
||||||
|
}, [data, isLoading, type]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="relative w-full max-w-4xl mx-4 bg-base rounded-xl border border-white/10 shadow-2xl animate-fade-in">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-white/5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white">{title}</h2>
|
||||||
|
<p className="text-xs text-muted">{code} · {type === 'stock' ? '个股' : '板块'} K线</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-lg hover:bg-hover transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-[400px]">
|
||||||
|
<div className="w-8 h-8 border-2 border-cyan/20 border-t-cyan rounded-full animate-spin" />
|
||||||
|
<p className="mt-3 text-secondary text-sm">加载K线数据...</p>
|
||||||
|
</div>
|
||||||
|
) : data && data.values.length > 0 ? (
|
||||||
|
<div ref={chartRef} className="w-full h-[400px]" />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-[400px]">
|
||||||
|
<p className="text-muted text-sm">暂无K线数据</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default KLineChartModal;
|
||||||
@ -0,0 +1,302 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Card, Badge } from '../components/common';
|
||||||
|
import { marketApi } from '../api/market';
|
||||||
|
import type {
|
||||||
|
SectorMomentum,
|
||||||
|
StockMomentum,
|
||||||
|
NewHighLowStock,
|
||||||
|
UpDownStats,
|
||||||
|
KLineChartResponse,
|
||||||
|
} from '../types/market';
|
||||||
|
import KLineChartModal from '../components/charts/KLineChartModal';
|
||||||
|
|
||||||
|
function formatPercent(value: number): string {
|
||||||
|
const sign = value >= 0 ? '+' : '';
|
||||||
|
return `${sign}${value.toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: number): string {
|
||||||
|
return value.toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChangeColor(value: number): string {
|
||||||
|
if (value > 0) return 'text-emerald-400';
|
||||||
|
if (value < 0) return 'text-red-400';
|
||||||
|
return 'text-secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRankChangeColor(change: number): string {
|
||||||
|
if (change > 0) return 'text-emerald-400'; // 排名提升
|
||||||
|
if (change < 0) return 'text-red-400'; // 排名下降
|
||||||
|
return 'text-secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRankChangeIcon(change: number): React.ReactNode {
|
||||||
|
if (change > 0) return '↑';
|
||||||
|
if (change < 0) return '↓';
|
||||||
|
return '→';
|
||||||
|
}
|
||||||
|
|
||||||
|
function recommendationBadge(rec?: string) {
|
||||||
|
if (!rec) return null;
|
||||||
|
switch (rec) {
|
||||||
|
case 'buy':
|
||||||
|
return <Badge variant="success" glow>BUY</Badge>;
|
||||||
|
case 'watch':
|
||||||
|
return <Badge variant="warning">WATCH</Badge>;
|
||||||
|
case 'hold':
|
||||||
|
return <Badge variant="default">HOLD</Badge>;
|
||||||
|
default:
|
||||||
|
return <Badge variant="default">{rec}</Badge>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TrendPage: React.FC = () => {
|
||||||
|
const [sectors, setSectors] = useState<SectorMomentum[]>([]);
|
||||||
|
const [stocks, setStocks] = useState<StockMomentum[]>([]);
|
||||||
|
const [newHigh, setNewHigh] = useState<NewHighLowStock[]>([]);
|
||||||
|
const [newLow, setNewLow] = useState<NewHighLowStock[]>([]);
|
||||||
|
const [updownStats, setUpdownStats] = useState<UpDownStats | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const [chartModal, setChartModal] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
type: 'stock' | 'sector';
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [chartData, setChartData] = useState<KLineChartResponse | null>(null);
|
||||||
|
const [isLoadingChart, setIsLoadingChart] = useState(false);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [sectorsRes, stocksRes, highRes, lowRes, statsRes] = await Promise.all([
|
||||||
|
marketApi.getSectorMomentum('momentumValue', 'desc', 5),
|
||||||
|
marketApi.getStockMomentumRecommendation(15),
|
||||||
|
marketApi.getNewHighStocks(20, 20),
|
||||||
|
marketApi.getNewLowStocks(20, 20),
|
||||||
|
marketApi.getUpDownStats(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setSectors(Array.isArray(sectorsRes?.items) ? sectorsRes.items : []);
|
||||||
|
setStocks(Array.isArray(stocksRes?.items) ? stocksRes.items : []);
|
||||||
|
setNewHigh(Array.isArray(highRes) ? highRes : []);
|
||||||
|
setNewLow(Array.isArray(lowRes) ? lowRes : []);
|
||||||
|
setUpdownStats(statsRes || null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch trend data:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleItemClick = async (type: 'stock' | 'sector', code: string, name: string) => {
|
||||||
|
setChartModal({ open: true, type, code, name });
|
||||||
|
setIsLoadingChart(true);
|
||||||
|
setChartData(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (type === 'stock') {
|
||||||
|
const data = await marketApi.getStockKline(code, 60);
|
||||||
|
setChartData(data);
|
||||||
|
} else {
|
||||||
|
const data = await marketApi.getSectorKline(code, 60);
|
||||||
|
setChartData(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch kline data:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingChart(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeChartModal = () => {
|
||||||
|
setChartModal(null);
|
||||||
|
setChartData(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<header className="flex-shrink-0 px-4 py-3 border-b border-white/5">
|
||||||
|
<h1 className="text-lg font-semibold text-white">趋势分析</h1>
|
||||||
|
<p className="text-xs text-muted mt-1">动量板块、个股推荐、新高新低分布</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto p-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64">
|
||||||
|
<div className="w-10 h-10 border-3 border-cyan/20 border-t-cyan rounded-full animate-spin" />
|
||||||
|
<p className="mt-3 text-secondary text-sm">加载中...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<Card variant="gradient" padding="md" className="animate-fade-in">
|
||||||
|
<div className="mb-3">
|
||||||
|
<span className="label-uppercase">动量板块 TOP5</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sectors.map((sector, idx) => (
|
||||||
|
<div
|
||||||
|
key={sector.code}
|
||||||
|
className="flex items-center justify-between py-2 px-3 rounded-lg bg-elevated/50 hover:bg-hover cursor-pointer transition-colors"
|
||||||
|
onClick={() => handleItemClick('sector', sector.code, sector.name)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted font-mono">{sector.rank || idx + 1}</span>
|
||||||
|
<span className="text-sm text-white">{sector.name}</span>
|
||||||
|
{sector.rank_change !== undefined && (
|
||||||
|
<span className={`text-xs font-mono ${getRankChangeColor(sector.rank_change)} ml-1`}>
|
||||||
|
{getRankChangeIcon(sector.rank_change)} {Math.abs(sector.rank_change)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs font-mono text-cyan">{formatValue(sector.momentum_value || 0)}</span>
|
||||||
|
{sector.momentum_value_change !== undefined && (
|
||||||
|
<span className={`text-xs font-mono ${getChangeColor(sector.momentum_value_change)}`}>
|
||||||
|
{formatValue(sector.momentum_value_change)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={`text-xs font-mono ${getChangeColor(sector.change_percent)}`}>
|
||||||
|
{formatPercent(sector.change_percent)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="gradient" padding="md" className="animate-fade-in">
|
||||||
|
<div className="mb-3">
|
||||||
|
<span className="label-uppercase">动量个股推荐</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||||
|
{stocks.map((stock, idx) => (
|
||||||
|
<div
|
||||||
|
key={stock.code}
|
||||||
|
className="flex items-center justify-between py-2 px-3 rounded-lg bg-elevated/50 hover:bg-hover cursor-pointer transition-colors"
|
||||||
|
onClick={() => handleItemClick('stock', stock.code, stock.name)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted font-mono">{idx + 1}</span>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-white">{stock.name}</span>
|
||||||
|
<span className="text-xs text-muted ml-1">{stock.code}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{recommendationBadge(stock.recommendation)}
|
||||||
|
<span className={`text-xs font-mono ${getChangeColor(stock.change_percent)}`}>
|
||||||
|
{formatPercent(stock.change_percent)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="gradient" padding="md" className="animate-fade-in">
|
||||||
|
<div className="mb-3">
|
||||||
|
<span className="label-uppercase">新高个股</span>
|
||||||
|
<span className="text-xs text-muted ml-2">(20日内)</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||||
|
{newHigh.slice(0, 10).map((stock) => (
|
||||||
|
<div
|
||||||
|
key={stock.code}
|
||||||
|
className="flex items-center justify-between py-2 px-3 rounded-lg bg-elevated/50 hover:bg-hover cursor-pointer transition-colors"
|
||||||
|
onClick={() => handleItemClick('stock', stock.code, stock.name)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-white">{stock.name}</span>
|
||||||
|
<span className="text-xs text-muted ml-1">{stock.code}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-mono text-emerald-400">{stock.price.toFixed(2)}</span>
|
||||||
|
<span className="text-xs font-mono text-emerald-400">{formatPercent(stock.change_percent)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="gradient" padding="md" className="animate-fade-in">
|
||||||
|
<div className="mb-3">
|
||||||
|
<span className="label-uppercase">新低个股</span>
|
||||||
|
<span className="text-xs text-muted ml-2">(20日内)</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||||
|
{newLow.slice(0, 10).map((stock) => (
|
||||||
|
<div
|
||||||
|
key={stock.code}
|
||||||
|
className="flex items-center justify-between py-2 px-3 rounded-lg bg-elevated/50 hover:bg-hover cursor-pointer transition-colors"
|
||||||
|
onClick={() => handleItemClick('stock', stock.code, stock.name)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-white">{stock.name}</span>
|
||||||
|
<span className="text-xs text-muted ml-1">{stock.code}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-mono text-red-400">{stock.price.toFixed(2)}</span>
|
||||||
|
<span className="text-xs font-mono text-red-400">{formatPercent(stock.change_percent)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{updownStats && (
|
||||||
|
<Card variant="gradient" padding="md" className="animate-fade-in lg:col-span-2">
|
||||||
|
<div className="mb-3">
|
||||||
|
<span className="label-uppercase">涨跌分布</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-5 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-mono text-emerald-400">{updownStats.up_count}</div>
|
||||||
|
<div className="text-xs text-muted mt-1">上涨</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-mono text-red-400">{updownStats.down_count}</div>
|
||||||
|
<div className="text-xs text-muted mt-1">下跌</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-mono text-secondary">{updownStats.flat_count}</div>
|
||||||
|
<div className="text-xs text-muted mt-1">平盘</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-mono text-emerald-400 glow">{updownStats.limit_up_count}</div>
|
||||||
|
<div className="text-xs text-muted mt-1">涨停</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-mono text-red-400">{updownStats.limit_down_count}</div>
|
||||||
|
<div className="text-xs text-muted mt-1">跌停</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{chartModal?.open && (
|
||||||
|
<KLineChartModal
|
||||||
|
title={chartModal.name}
|
||||||
|
code={chartModal.code}
|
||||||
|
type={chartModal.type}
|
||||||
|
data={chartData}
|
||||||
|
isLoading={isLoadingChart}
|
||||||
|
onClose={closeChartModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrendPage;
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
export interface MarketIndex {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
change?: number;
|
||||||
|
change_percent?: number;
|
||||||
|
volume?: number;
|
||||||
|
amount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpDownStats {
|
||||||
|
up_count: number;
|
||||||
|
down_count: number;
|
||||||
|
flat_count: number;
|
||||||
|
limit_up_count: number;
|
||||||
|
limit_down_count: number;
|
||||||
|
total_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectorMomentum {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
momentum_score: number;
|
||||||
|
change_percent: number;
|
||||||
|
turnover_rate?: number;
|
||||||
|
leading_stock?: string;
|
||||||
|
leading_stock_name?: string;
|
||||||
|
momentum_value?: number;
|
||||||
|
momentum_value_change?: number;
|
||||||
|
rank?: number;
|
||||||
|
rank_change?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockMomentum {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
momentum_score: number;
|
||||||
|
change_percent: number;
|
||||||
|
sector?: string;
|
||||||
|
recommendation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewHighLowStock {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
change_percent: number;
|
||||||
|
days_to_high?: number;
|
||||||
|
days_to_low?: number;
|
||||||
|
high_date?: string;
|
||||||
|
low_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceDistribution {
|
||||||
|
range_label: string;
|
||||||
|
count: number;
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SentimentIndicator {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
change?: number;
|
||||||
|
level?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KLineChartResponse {
|
||||||
|
categoryData: string[];
|
||||||
|
values: number[][];
|
||||||
|
volumes: number[][];
|
||||||
|
stock_name?: string;
|
||||||
|
}
|
||||||
@ -0,0 +1,443 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
===================================
|
||||||
|
AmazingData Custom Data Source Adapter
|
||||||
|
===================================
|
||||||
|
|
||||||
|
Adapter for AmazingData financial data platform API.
|
||||||
|
Provides stock K-line data, realtime quotes, and market statistics.
|
||||||
|
|
||||||
|
API Documentation: docs/customApi/API.md
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from .base import BaseFetcher, DataFetchError, RateLimitError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AmazingDataFetcher(BaseFetcher):
|
||||||
|
"""
|
||||||
|
AmazingData data source adapter
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- JWT authentication with auto-refresh
|
||||||
|
- Stock K-line data (daily/minute)
|
||||||
|
- Realtime quotes
|
||||||
|
- Market statistics
|
||||||
|
- Sector rankings
|
||||||
|
|
||||||
|
Configuration (via .env):
|
||||||
|
- AMAZINGDATA_BASE_URL: API base URL (default: http://localhost:8000/api/v1)
|
||||||
|
- AMAZINGDATA_USERNAME: SDK username
|
||||||
|
- AMAZINGDATA_PASSWORD: SDK password
|
||||||
|
- AMAZINGDATA_HOST: SDK server host (default: 140.206.44.234)
|
||||||
|
- AMAZINGDATA_PORT: SDK server port (default: 8600)
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = "AmazingDataFetcher"
|
||||||
|
priority: int = 5
|
||||||
|
|
||||||
|
DEFAULT_BASE_URL = "http://localhost:8000/api/v1"
|
||||||
|
DEFAULT_HOST = "140.206.44.234"
|
||||||
|
DEFAULT_PORT = 8600
|
||||||
|
TOKEN_EXPIRY_BUFFER = 300
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize AmazingData fetcher"""
|
||||||
|
self._base_url: Optional[str] = None
|
||||||
|
self._username: Optional[str] = None
|
||||||
|
self._password: Optional[str] = None
|
||||||
|
self._host: Optional[str] = None
|
||||||
|
self._port: Optional[int] = None
|
||||||
|
|
||||||
|
self._token: Optional[str] = None
|
||||||
|
self._token_expires_at: Optional[float] = None
|
||||||
|
|
||||||
|
self._initialized = False
|
||||||
|
self._available = False
|
||||||
|
|
||||||
|
self._init_config()
|
||||||
|
|
||||||
|
def _init_config(self) -> None:
|
||||||
|
"""Load configuration from environment"""
|
||||||
|
try:
|
||||||
|
from src.config import get_config
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
self._base_url = getattr(config, 'amazingdata_base_url', None) or self.DEFAULT_BASE_URL
|
||||||
|
self._username = getattr(config, 'amazingdata_username', None)
|
||||||
|
self._password = getattr(config, 'amazingdata_password', None)
|
||||||
|
self._host = getattr(config, 'amazingdata_host', None) or self.DEFAULT_HOST
|
||||||
|
self._port = getattr(config, 'amazingdata_port', None) or self.DEFAULT_PORT
|
||||||
|
|
||||||
|
if self._username and self._password:
|
||||||
|
self._initialized = True
|
||||||
|
self._available = True
|
||||||
|
self.priority = 0
|
||||||
|
logger.info(f"[{self.name}] Initialized with base_url={self._base_url}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"[{self.name}] Not configured (missing username/password)")
|
||||||
|
self._available = False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[{self.name}] Config load failed: {e}")
|
||||||
|
self._available = False
|
||||||
|
|
||||||
|
def _login(self) -> bool:
|
||||||
|
"""Authenticate and obtain JWT token"""
|
||||||
|
if not self._initialized:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{self._base_url}/auth/login",
|
||||||
|
json={"username": self._username, "password": self._password},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
if data.get("code") == 200:
|
||||||
|
token_data = data.get("data", {})
|
||||||
|
self._token = token_data.get("access_token")
|
||||||
|
expires_in = token_data.get("expires_in", 86400)
|
||||||
|
self._token_expires_at = time.time() + expires_in - self.TOKEN_EXPIRY_BUFFER
|
||||||
|
logger.info(f"[{self.name}] Login successful, token valid for {expires_in}s")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f"[{self.name}] Login failed: {data.get('message')}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.warning(f"[{self.name}] Login HTTP error: {response.status_code}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[{self.name}] Login exception: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_headers(self) -> Dict[str, str]:
|
||||||
|
"""Get authorization headers"""
|
||||||
|
if not self._token or (self._token_expires_at and time.time() > self._token_expires_at):
|
||||||
|
if not self._login():
|
||||||
|
raise DataFetchError(f"[{self.name}] Authentication failed")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self._token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
def _request(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
|
||||||
|
"""Make authenticated API request"""
|
||||||
|
headers = self._get_headers()
|
||||||
|
kwargs["headers"] = headers
|
||||||
|
kwargs["timeout"] = kwargs.get("timeout", 60)
|
||||||
|
|
||||||
|
url = f"{self._base_url}{path}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.request(method, url, **kwargs)
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
logger.warning(f"[{self.name}] Token expired, re-authenticating...")
|
||||||
|
self._token = None
|
||||||
|
headers = self._get_headers()
|
||||||
|
kwargs["headers"] = headers
|
||||||
|
response = requests.request(method, url, **kwargs)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
else:
|
||||||
|
raise DataFetchError(f"[{self.name}] API error {response.status_code}: {response.text}")
|
||||||
|
|
||||||
|
except requests.Timeout:
|
||||||
|
raise DataFetchError(f"[{self.name}] Request timeout")
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise DataFetchError(f"[{self.name}] Request failed: {e}")
|
||||||
|
|
||||||
|
def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Fetch raw K-line data from AmazingData
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stock_code: Stock code (e.g., '600519')
|
||||||
|
start_date: Start date (YYYY-MM-DD)
|
||||||
|
end_date: End date (YYYY-MM-DD)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Raw DataFrame with K-line data
|
||||||
|
"""
|
||||||
|
if not self._available:
|
||||||
|
raise DataFetchError(f"[{self.name}] Not configured")
|
||||||
|
|
||||||
|
code_with_suffix = self._add_exchange_suffix(stock_code)
|
||||||
|
start_dt = start_date.replace("-", "")
|
||||||
|
end_dt = end_date.replace("-", "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._request("GET", "/stock/kline", params={
|
||||||
|
"codes": code_with_suffix,
|
||||||
|
"start_date": start_dt,
|
||||||
|
"end_date": end_dt,
|
||||||
|
"period": "daily"
|
||||||
|
})
|
||||||
|
|
||||||
|
data = response.get("data", {})
|
||||||
|
kline_list = data.get(code_with_suffix, [])
|
||||||
|
|
||||||
|
if not kline_list:
|
||||||
|
raise DataFetchError(f"[{self.name}] No data for {stock_code}")
|
||||||
|
|
||||||
|
df = pd.DataFrame(kline_list)
|
||||||
|
return df
|
||||||
|
|
||||||
|
except DataFetchError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise DataFetchError(f"[{self.name}] Fetch failed: {e}")
|
||||||
|
|
||||||
|
def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Normalize column names to standard format
|
||||||
|
|
||||||
|
Standard columns: date, open, high, low, close, volume, amount, pct_chg
|
||||||
|
"""
|
||||||
|
column_mapping = {
|
||||||
|
"trade_date": "date",
|
||||||
|
"open": "open",
|
||||||
|
"high": "high",
|
||||||
|
"low": "low",
|
||||||
|
"close": "close",
|
||||||
|
"volume": "volume",
|
||||||
|
"amount": "amount",
|
||||||
|
}
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
|
||||||
|
for old_col, new_col in column_mapping.items():
|
||||||
|
if old_col in df.columns and new_col not in df.columns:
|
||||||
|
df[new_col] = df[old_col]
|
||||||
|
|
||||||
|
if "pct_chg" not in df.columns:
|
||||||
|
if "close" in df.columns and len(df) > 1:
|
||||||
|
df["pct_chg"] = df["close"].pct_change() * 100
|
||||||
|
else:
|
||||||
|
df["pct_chg"] = 0.0
|
||||||
|
|
||||||
|
required_cols = ["date", "open", "high", "low", "close", "volume"]
|
||||||
|
missing_cols = [col for col in required_cols if col not in df.columns]
|
||||||
|
if missing_cols:
|
||||||
|
raise DataFetchError(f"[{self.name}] Missing columns: {missing_cols}")
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
def _add_exchange_suffix(self, stock_code: str) -> str:
|
||||||
|
"""Add exchange suffix to stock code"""
|
||||||
|
code = stock_code.strip()
|
||||||
|
if "." in code:
|
||||||
|
return code
|
||||||
|
|
||||||
|
if code.startswith("6"):
|
||||||
|
return f"{code}.SH"
|
||||||
|
elif code.startswith(("0", "3")):
|
||||||
|
return f"{code}.SZ"
|
||||||
|
elif code.startswith("68"):
|
||||||
|
return f"{code}.SH"
|
||||||
|
else:
|
||||||
|
return f"{code}.SH"
|
||||||
|
|
||||||
|
def get_realtime_quote(self, stock_code: str):
|
||||||
|
"""
|
||||||
|
Get realtime quote for a stock
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stock_code: Stock code
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UnifiedRealtimeQuote object or None
|
||||||
|
"""
|
||||||
|
if not self._available:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
code_with_suffix = self._add_exchange_suffix(stock_code)
|
||||||
|
|
||||||
|
today = datetime.now().strftime("%Y%m%d")
|
||||||
|
week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y%m%d")
|
||||||
|
|
||||||
|
response = self._request("GET", "/stock/kline", params={
|
||||||
|
"codes": code_with_suffix,
|
||||||
|
"start_date": week_ago,
|
||||||
|
"end_date": today,
|
||||||
|
"period": "daily"
|
||||||
|
})
|
||||||
|
|
||||||
|
data = response.get("data", {})
|
||||||
|
kline_list = data.get(code_with_suffix, [])
|
||||||
|
|
||||||
|
if not kline_list:
|
||||||
|
return None
|
||||||
|
|
||||||
|
latest = kline_list[-1]
|
||||||
|
prev = kline_list[-2] if len(kline_list) > 1 else latest
|
||||||
|
|
||||||
|
from .realtime_types import UnifiedRealtimeQuote
|
||||||
|
|
||||||
|
close = float(latest.get("close", 0))
|
||||||
|
prev_close = float(prev.get("close", close))
|
||||||
|
change = close - prev_close
|
||||||
|
change_pct = (change / prev_close * 100) if prev_close else 0
|
||||||
|
|
||||||
|
quote = UnifiedRealtimeQuote(
|
||||||
|
code=stock_code,
|
||||||
|
name=None,
|
||||||
|
price=close,
|
||||||
|
open=float(latest.get("open", 0)),
|
||||||
|
high=float(latest.get("high", 0)),
|
||||||
|
low=float(latest.get("low", 0)),
|
||||||
|
prev_close=prev_close,
|
||||||
|
volume=float(latest.get("volume", 0) or 0),
|
||||||
|
amount=float(latest.get("amount", 0) or 0),
|
||||||
|
change_amount=change,
|
||||||
|
change_pct=change_pct,
|
||||||
|
)
|
||||||
|
|
||||||
|
return quote
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[{self.name}] Realtime quote failed for {stock_code}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_main_indices(self) -> Optional[List[Dict[str, Any]]]:
|
||||||
|
"""Get major market indices realtime data"""
|
||||||
|
if not self._available:
|
||||||
|
return None
|
||||||
|
|
||||||
|
indices_codes = ["000001.SH", "399001.SZ", "399006.SZ", "000698.SH"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
today = datetime.now().strftime("%Y%m%d")
|
||||||
|
week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y%m%d")
|
||||||
|
|
||||||
|
response = self._request("GET", "/stock/kline", params={
|
||||||
|
"codes": ",".join(indices_codes),
|
||||||
|
"start_date": week_ago,
|
||||||
|
"end_date": today,
|
||||||
|
"period": "daily"
|
||||||
|
})
|
||||||
|
|
||||||
|
data = response.get("data", {})
|
||||||
|
result = []
|
||||||
|
|
||||||
|
index_names = {
|
||||||
|
"000001.SH": "上证指数",
|
||||||
|
"399001.SZ": "深证成指",
|
||||||
|
"399006.SZ": "创业板指",
|
||||||
|
"000698.SH": "科创50",
|
||||||
|
}
|
||||||
|
|
||||||
|
for code in indices_codes:
|
||||||
|
kline_list = data.get(code, [])
|
||||||
|
if kline_list:
|
||||||
|
latest = kline_list[-1]
|
||||||
|
prev = kline_list[-2] if len(kline_list) > 1 else latest
|
||||||
|
|
||||||
|
close = float(latest.get("close", 0))
|
||||||
|
prev_close = float(prev.get("close", close))
|
||||||
|
change = close - prev_close
|
||||||
|
change_pct = (change / prev_close * 100) if prev_close else 0
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
"code": code,
|
||||||
|
"name": index_names.get(code, code),
|
||||||
|
"current": close,
|
||||||
|
"change": change,
|
||||||
|
"change_pct": change_pct,
|
||||||
|
"volume": float(latest.get("volume", 0) or 0),
|
||||||
|
"amount": float(latest.get("amount", 0) or 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
return result if result else None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[{self.name}] Get indices failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_market_stats(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get market up/down statistics"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_sector_rankings(self, n: int = 5) -> Optional[Tuple[List[Dict], List[Dict]]]:
|
||||||
|
"""Get sector rankings"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_stock_name(self, stock_code: str) -> Optional[str]:
|
||||||
|
"""Get stock name from API"""
|
||||||
|
if not self._available:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
code_with_suffix = self._add_exchange_suffix(stock_code)
|
||||||
|
|
||||||
|
response = self._request("GET", f"/base/codes/{code_with_suffix}/info")
|
||||||
|
|
||||||
|
data = response.get("data", {})
|
||||||
|
name = data.get("name")
|
||||||
|
|
||||||
|
if name:
|
||||||
|
logger.debug(f"[{self.name}] Got name for {stock_code}: {name}")
|
||||||
|
return name
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[{self.name}] Get stock name failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_kline_chart_data(self, stock_code: str, days: int = 60) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get K-line chart data formatted for ECharts
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stock_code: Stock code
|
||||||
|
days: Number of days
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with categoryData, values, volumes for ECharts
|
||||||
|
"""
|
||||||
|
if not self._available:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
code_with_suffix = self._add_exchange_suffix(stock_code)
|
||||||
|
end_date = datetime.now().strftime("%Y%m%d")
|
||||||
|
start_date = (datetime.now() - timedelta(days=days * 2)).strftime("%Y%m%d")
|
||||||
|
|
||||||
|
response = self._request("GET", f"/stock/kline/{code_with_suffix}/chart", params={
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"period": "daily"
|
||||||
|
})
|
||||||
|
|
||||||
|
data = response.get("data", {})
|
||||||
|
|
||||||
|
if data:
|
||||||
|
return {
|
||||||
|
"categoryData": data.get("categoryData", []),
|
||||||
|
"values": data.get("values", []),
|
||||||
|
"volumes": data.get("volumes", []),
|
||||||
|
"stock_name": data.get("stock_name"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[{self.name}] Get kline chart failed: {e}")
|
||||||
|
return None
|
||||||
@ -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);
|
||||||
@ -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
|
||||||
Loading…
Reference in new issue