Initial commit: stock market platform
This commit is contained in:
16
.env.example
Normal file
16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Copy this file to .env and fill in your values
|
||||||
|
POSTGRES_USER=stock
|
||||||
|
POSTGRES_PASSWORD=your_password_here
|
||||||
|
POSTGRES_DB=stockdb
|
||||||
|
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
CELERY_BROKER_URL=redis://redis:6379/1
|
||||||
|
|
||||||
|
SECRET_KEY=your-super-secret-key-change-this-in-production-please
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
|
||||||
|
DEBUG=false
|
||||||
|
ALLOWED_ORIGINS=http://localhost:3000
|
||||||
|
|
||||||
|
# TUSHARE_TOKEN=your_tushare_token_here
|
||||||
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Environment (secrets)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Node / Frontend
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# Celery
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# Docker (local overrides)
|
||||||
|
docker-compose.override.yml
|
||||||
121
README.md
Normal file
121
README.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# 股票行情平台
|
||||||
|
|
||||||
|
全栈股票行情系统:实时大盘云图 · 多周期K线 · 自选股 · 价格预警
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|---|---|
|
||||||
|
| 前端 | React 18 + TypeScript + Vite + Ant Design 5 + ECharts + LightweightCharts |
|
||||||
|
| 后端 | Python 3.12 + FastAPI + SQLAlchemy 2 + Celery |
|
||||||
|
| 数据 | AKShare (免费A股数据) + Tushare Pro (可选) |
|
||||||
|
| 存储 | PostgreSQL 16 + Redis 7 |
|
||||||
|
| 部署 | Docker Compose + Nginx |
|
||||||
|
|
||||||
|
## 快速启动
|
||||||
|
|
||||||
|
### 方式一:Docker Compose(推荐,跨平台通用)
|
||||||
|
|
||||||
|
> 前提:安装 [Docker Desktop](https://www.docker.com/products/docker-desktop/)(Windows/Mac/Linux 均支持)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 复制环境变量
|
||||||
|
Copy-Item .env.example .env
|
||||||
|
# 编辑 .env,修改密码等配置(记事本或 VS Code 打开)
|
||||||
|
|
||||||
|
# 启动所有服务
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 查看后端日志
|
||||||
|
docker-compose logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
访问:
|
||||||
|
- 前端:http://localhost:3000
|
||||||
|
- API 文档:http://localhost:8000/api/docs
|
||||||
|
- Nginx 入口:http://localhost
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 方式二:Windows 本地开发(不打包进 Docker)
|
||||||
|
|
||||||
|
> 前提:安装 Python 3.12、Node.js 20、Docker Desktop(仅用于跑数据库)
|
||||||
|
|
||||||
|
**一键启动脚本(推荐)**
|
||||||
|
```powershell
|
||||||
|
# 在项目根目录用 PowerShell 运行
|
||||||
|
.\scripts\dev-windows.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会自动:启动 PostgreSQL+Redis(Docker)→ 启动 FastAPI → 启动 Celery → 启动 Vite
|
||||||
|
|
||||||
|
**手动启动步骤**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 第1步:只启动数据库(Docker)
|
||||||
|
docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# 第2步:后端
|
||||||
|
cd backend
|
||||||
|
python -m venv venv
|
||||||
|
.\venv\Scripts\Activate.ps1
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
|
||||||
|
# 第3步:Celery(新开一个 PowerShell 窗口,Windows 必须加 --pool=solo)
|
||||||
|
cd backend
|
||||||
|
.\venv\Scripts\Activate.ps1
|
||||||
|
celery -A celery_app.worker worker -l info --pool=solo
|
||||||
|
|
||||||
|
# 第4步:前端(新开一个 PowerShell 窗口)
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
访问:
|
||||||
|
- 前端:http://localhost:5173
|
||||||
|
- API 文档:http://localhost:8000/api/docs
|
||||||
|
|
||||||
|
## 功能页面
|
||||||
|
|
||||||
|
| 页面 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 登录/注册 | `/login` | JWT 认证 |
|
||||||
|
| 大盘云图 | `/` | A股实时热力图,WebSocket 推送 |
|
||||||
|
| 股票详情 | `/stock/:symbol` | 分时/五日/日K/周K/月K |
|
||||||
|
| 自选股 | `/watchlist` | 自选股管理 |
|
||||||
|
| 价格预警 | `/alerts` | 价格/涨跌幅预警配置 |
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
stock/
|
||||||
|
├── backend/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── main.py # FastAPI 入口 + WebSocket
|
||||||
|
│ │ ├── core/ # 配置、数据库、Redis、JWT
|
||||||
|
│ │ ├── api/v1/ # 路由:auth, stocks, watchlist, alerts
|
||||||
|
│ │ ├── models/ # SQLAlchemy ORM 模型
|
||||||
|
│ │ ├── schemas/ # Pydantic 校验
|
||||||
|
│ │ ├── services/ # AKShare 数据服务
|
||||||
|
│ │ └── websocket/ # WebSocket 连接管理
|
||||||
|
│ └── celery_app/ # 定时任务
|
||||||
|
├── frontend/
|
||||||
|
│ └── src/
|
||||||
|
│ ├── pages/ # Login, Home, StockDetail, Watchlist, Alerts
|
||||||
|
│ ├── components/ # Layout, Charts(K线/分时/热力图)
|
||||||
|
│ ├── services/ # Axios API 封装
|
||||||
|
│ ├── stores/ # Zustand 状态管理
|
||||||
|
│ └── hooks/ # useWebSocket
|
||||||
|
├── nginx/nginx.conf
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── .env
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据源
|
||||||
|
|
||||||
|
- **AKShare**:免费,支持 A股/港股/美股/基金,日线/分钟K线/实时行情
|
||||||
|
- **Tushare Pro**:积分制,专业复权数据(在 `.env` 中配置 `TUSHARE_TOKEN`)
|
||||||
|
|
||||||
|
> 注:AKShare 调用新浪/东财公开接口,数据有 15-30 分钟延迟(非付费实时数据源)
|
||||||
16
backend/Dockerfile
Normal file
16
backend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc libpq-dev curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
35
backend/app/api/deps.py
Normal file
35
backend/app/api/deps.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import decode_token
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
bearer = HTTPBearer(auto_error=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(bearer),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
token = credentials.credentials
|
||||||
|
payload = decode_token(token)
|
||||||
|
|
||||||
|
if payload is None or payload.get("type") != "access":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Token 无效或已过期",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = int(payload["sub"])
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="用户不存在或已被禁用",
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
8
backend/app/api/v1/__init__.py
Normal file
8
backend/app/api/v1/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
from app.api.v1 import auth, stocks, watchlist, alerts
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(auth.router)
|
||||||
|
api_router.include_router(stocks.router)
|
||||||
|
api_router.include_router(watchlist.router)
|
||||||
|
api_router.include_router(alerts.router)
|
||||||
77
backend/app/api/v1/alerts.py
Normal file
77
backend/app/api/v1/alerts.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, delete
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.alert import Alert
|
||||||
|
from app.schemas.stock import AlertCreate, AlertOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/alerts", tags=["alerts"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[AlertOut])
|
||||||
|
async def get_alerts(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Alert)
|
||||||
|
.where(Alert.user_id == current_user.id)
|
||||||
|
.order_by(Alert.id.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=AlertOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_alert(
|
||||||
|
payload: AlertCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
alert = Alert(
|
||||||
|
user_id=current_user.id,
|
||||||
|
symbol=payload.symbol,
|
||||||
|
name=payload.name,
|
||||||
|
alert_type=payload.alert_type,
|
||||||
|
threshold=payload.threshold,
|
||||||
|
)
|
||||||
|
db.add(alert)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(alert)
|
||||||
|
return alert
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{alert_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_alert(
|
||||||
|
alert_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Alert).where(Alert.id == alert_id, Alert.user_id == current_user.id)
|
||||||
|
)
|
||||||
|
alert = result.scalar_one_or_none()
|
||||||
|
if not alert:
|
||||||
|
raise HTTPException(status_code=404, detail="预警不存在")
|
||||||
|
|
||||||
|
await db.delete(alert)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{alert_id}/toggle", response_model=AlertOut)
|
||||||
|
async def toggle_alert(
|
||||||
|
alert_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Alert).where(Alert.id == alert_id, Alert.user_id == current_user.id)
|
||||||
|
)
|
||||||
|
alert = result.scalar_one_or_none()
|
||||||
|
if not alert:
|
||||||
|
raise HTTPException(status_code=404, detail="预警不存在")
|
||||||
|
|
||||||
|
alert.is_active = not alert.is_active
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(alert)
|
||||||
|
return alert
|
||||||
78
backend/app/api/v1/auth.py
Normal file
78
backend/app/api/v1/auth.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import (
|
||||||
|
hash_password, verify_password,
|
||||||
|
create_access_token, create_refresh_token, decode_token,
|
||||||
|
)
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.auth import (
|
||||||
|
RegisterRequest, LoginRequest, TokenResponse,
|
||||||
|
RefreshRequest, UserOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(
|
||||||
|
(User.username == payload.username) | (User.email == payload.email)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="用户名或邮箱已被使用")
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
username=payload.username,
|
||||||
|
email=payload.email,
|
||||||
|
hashed_password=hash_password(payload.password),
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(select(User).where(User.username == payload.username))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user or not verify_password(payload.password, user.hashed_password):
|
||||||
|
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status_code=403, detail="账号已被禁用")
|
||||||
|
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=create_access_token(user.id),
|
||||||
|
refresh_token=create_refresh_token(user.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
|
async def refresh(payload: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
token_data = decode_token(payload.refresh_token)
|
||||||
|
|
||||||
|
if token_data is None or token_data.get("type") != "refresh":
|
||||||
|
raise HTTPException(status_code=401, detail="Refresh token 无效或已过期")
|
||||||
|
|
||||||
|
user_id = int(token_data["sub"])
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(status_code=401, detail="用户不存在")
|
||||||
|
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=create_access_token(user.id),
|
||||||
|
refresh_token=create_refresh_token(user.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserOut)
|
||||||
|
async def get_me(current_user: User = Depends(__import__("app.api.deps", fromlist=["get_current_user"]).get_current_user)):
|
||||||
|
return current_user
|
||||||
133
backend/app/api/v1/stocks.py
Normal file
133
backend/app/api/v1/stocks.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import json
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.core.redis import get_redis
|
||||||
|
from app.services import stock_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/stocks", tags=["stocks"])
|
||||||
|
|
||||||
|
CACHE_TTL = 30 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
# ── market overview ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/market/overview")
|
||||||
|
async def market_overview(current_user: User = Depends(get_current_user)):
|
||||||
|
redis = await get_redis()
|
||||||
|
cache_key = "market:overview"
|
||||||
|
cached = await redis.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return json.loads(cached)
|
||||||
|
|
||||||
|
data = await stock_service.get_market_overview()
|
||||||
|
await redis.setex(cache_key, CACHE_TTL, json.dumps(data))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ── heatmap ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/market/heatmap")
|
||||||
|
async def market_heatmap(current_user: User = Depends(get_current_user)):
|
||||||
|
redis = await get_redis()
|
||||||
|
cache_key = "market:heatmap"
|
||||||
|
cached = await redis.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return json.loads(cached)
|
||||||
|
|
||||||
|
data = await stock_service.get_all_stocks_spot()
|
||||||
|
await redis.setex(cache_key, CACHE_TTL, json.dumps(data))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ── sector ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/market/sectors")
|
||||||
|
async def market_sectors(current_user: User = Depends(get_current_user)):
|
||||||
|
redis = await get_redis()
|
||||||
|
cache_key = "market:sectors"
|
||||||
|
cached = await redis.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return json.loads(cached)
|
||||||
|
|
||||||
|
data = await stock_service.get_sector_spot()
|
||||||
|
await redis.setex(cache_key, CACHE_TTL, json.dumps(data))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ── single stock quote ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/{symbol}/quote")
|
||||||
|
async def stock_quote(symbol: str, current_user: User = Depends(get_current_user)):
|
||||||
|
redis = await get_redis()
|
||||||
|
cache_key = f"quote:{symbol}"
|
||||||
|
cached = await redis.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return json.loads(cached)
|
||||||
|
|
||||||
|
data = await stock_service.get_stock_quote(symbol)
|
||||||
|
if data:
|
||||||
|
await redis.setex(cache_key, CACHE_TTL, json.dumps(data))
|
||||||
|
return data or {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── K-line ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/{symbol}/kline")
|
||||||
|
async def stock_kline(
|
||||||
|
symbol: str,
|
||||||
|
period: str = Query("daily", pattern="^(daily|weekly|monthly)$"),
|
||||||
|
adjust: str = Query("qfq", pattern="^(qfq|hfq|)$"),
|
||||||
|
limit: int = Query(250, ge=10, le=1000),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
redis = await get_redis()
|
||||||
|
cache_key = f"kline:{symbol}:{period}:{adjust}:{limit}"
|
||||||
|
cached = await redis.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return json.loads(cached)
|
||||||
|
|
||||||
|
data = await stock_service.get_kline(symbol, period, adjust, limit)
|
||||||
|
await redis.setex(cache_key, 300, json.dumps(data))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ── intraday ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/{symbol}/intraday")
|
||||||
|
async def stock_intraday(symbol: str, current_user: User = Depends(get_current_user)):
|
||||||
|
redis = await get_redis()
|
||||||
|
cache_key = f"intraday:{symbol}"
|
||||||
|
cached = await redis.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return json.loads(cached)
|
||||||
|
|
||||||
|
data = await stock_service.get_intraday(symbol)
|
||||||
|
await redis.setex(cache_key, CACHE_TTL, json.dumps(data))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ── 5-day ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/{symbol}/fiveday")
|
||||||
|
async def stock_fiveday(symbol: str, current_user: User = Depends(get_current_user)):
|
||||||
|
redis = await get_redis()
|
||||||
|
cache_key = f"fiveday:{symbol}"
|
||||||
|
cached = await redis.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return json.loads(cached)
|
||||||
|
|
||||||
|
data = await stock_service.get_five_day(symbol)
|
||||||
|
await redis.setex(cache_key, CACHE_TTL, json.dumps(data))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ── search ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/search")
|
||||||
|
async def search(
|
||||||
|
q: str = Query(..., min_length=1),
|
||||||
|
limit: int = Query(20, ge=1, le=50),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return await stock_service.search_stocks(q, limit)
|
||||||
63
backend/app/api/v1/watchlist.py
Normal file
63
backend/app/api/v1/watchlist.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, delete
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.watchlist import Watchlist
|
||||||
|
from app.schemas.stock import WatchlistItem, WatchlistAddRequest
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/watchlist", tags=["watchlist"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[WatchlistItem])
|
||||||
|
async def get_watchlist(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Watchlist)
|
||||||
|
.where(Watchlist.user_id == current_user.id)
|
||||||
|
.order_by(Watchlist.sort_order, Watchlist.id)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=WatchlistItem, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def add_to_watchlist(
|
||||||
|
payload: WatchlistAddRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
existing = await db.execute(
|
||||||
|
select(Watchlist).where(
|
||||||
|
Watchlist.user_id == current_user.id,
|
||||||
|
Watchlist.symbol == payload.symbol,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=409, detail="已在自选股中")
|
||||||
|
|
||||||
|
item = Watchlist(
|
||||||
|
user_id=current_user.id,
|
||||||
|
symbol=payload.symbol,
|
||||||
|
name=payload.name,
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{symbol}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def remove_from_watchlist(
|
||||||
|
symbol: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await db.execute(
|
||||||
|
delete(Watchlist).where(
|
||||||
|
Watchlist.user_id == current_user.id,
|
||||||
|
Watchlist.symbol == symbol,
|
||||||
|
)
|
||||||
|
)
|
||||||
41
backend/app/core/config.py
Normal file
41
backend/app/core/config.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from pydantic import field_validator
|
||||||
|
from typing import List
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# App
|
||||||
|
APP_NAME: str = "Stock Platform API"
|
||||||
|
DEBUG: bool = False
|
||||||
|
API_V1_PREFIX: str = "/api/v1"
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: str = "postgresql+asyncpg://stock:stockpass@localhost:5432/stockdb"
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL: str = "redis://localhost:6379/0"
|
||||||
|
CELERY_BROKER_URL: str = "redis://localhost:6379/1"
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
SECRET_KEY: str = "change-me-in-production"
|
||||||
|
ALGORITHM: str = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
ALLOWED_ORIGINS: str = "http://localhost:3000,http://localhost:5173"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origins(self) -> List[str]:
|
||||||
|
return [o.strip() for o in self.ALLOWED_ORIGINS.split(",")]
|
||||||
|
|
||||||
|
# Tushare (optional)
|
||||||
|
TUSHARE_TOKEN: str = ""
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
37
backend/app/core/database.py
Normal file
37
backend/app/core/database.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
echo=settings.DEBUG,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
|
bind=engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db() -> None:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
22
backend/app/core/redis.py
Normal file
22
backend/app/core/redis.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import redis.asyncio as aioredis
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_redis_pool: aioredis.Redis | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_redis() -> aioredis.Redis:
|
||||||
|
global _redis_pool
|
||||||
|
if _redis_pool is None:
|
||||||
|
_redis_pool = aioredis.from_url(
|
||||||
|
settings.REDIS_URL,
|
||||||
|
encoding="utf-8",
|
||||||
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
return _redis_pool
|
||||||
|
|
||||||
|
|
||||||
|
async def close_redis() -> None:
|
||||||
|
global _redis_pool
|
||||||
|
if _redis_pool:
|
||||||
|
await _redis_pool.aclose()
|
||||||
|
_redis_pool = None
|
||||||
37
backend/app/core/security.py
Normal file
37
backend/app/core/security.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(subject: str | int, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + (
|
||||||
|
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
)
|
||||||
|
payload = {"sub": str(subject), "exp": expire, "type": "access"}
|
||||||
|
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(subject: str | int) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||||
|
payload = {"sub": str(subject), "exp": expire, "type": "refresh"}
|
||||||
|
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
89
backend/app/main.py
Normal file
89
backend/app/main.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import init_db
|
||||||
|
from app.core.redis import close_redis
|
||||||
|
from app.api.v1 import api_router
|
||||||
|
from app.websocket.manager import manager
|
||||||
|
from app.services import stock_service
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
|
||||||
|
|
||||||
|
# ── background task: push heatmap every 5s ───────────────────────────────────
|
||||||
|
|
||||||
|
async def _heatmap_pusher():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data = await stock_service.get_all_stocks_spot()
|
||||||
|
await manager.broadcast_all(data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"heatmap pusher error: {e}")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
logger.info("Starting up...")
|
||||||
|
await init_db()
|
||||||
|
task = asyncio.create_task(_heatmap_pusher())
|
||||||
|
yield
|
||||||
|
task.cancel()
|
||||||
|
await close_redis()
|
||||||
|
logger.info("Shut down.")
|
||||||
|
|
||||||
|
|
||||||
|
# ── app ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.APP_NAME,
|
||||||
|
docs_url="/api/docs",
|
||||||
|
redoc_url="/api/redoc",
|
||||||
|
openapi_url="/api/openapi.json",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
|
# ── WebSocket endpoints ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.websocket("/ws/heatmap")
|
||||||
|
async def ws_heatmap(websocket: WebSocket):
|
||||||
|
"""Subscribe to full market heatmap updates."""
|
||||||
|
await manager.connect(websocket)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
manager.disconnect(websocket)
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/quote/{symbol}")
|
||||||
|
async def ws_quote(websocket: WebSocket, symbol: str):
|
||||||
|
"""Subscribe to real-time quote updates for a single stock."""
|
||||||
|
await manager.connect(websocket, symbol)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
data = await stock_service.get_stock_quote(symbol)
|
||||||
|
if data:
|
||||||
|
await manager.broadcast_quote(symbol, data)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
manager.disconnect(websocket, symbol)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
5
backend/app/models/__init__.py
Normal file
5
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from app.models.user import User
|
||||||
|
from app.models.watchlist import Watchlist
|
||||||
|
from app.models.alert import Alert, AlertType
|
||||||
|
|
||||||
|
__all__ = ["User", "Watchlist", "Alert", "AlertType"]
|
||||||
29
backend/app/models/alert.py
Normal file
29
backend/app/models/alert.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, Float, Boolean, ForeignKey, DateTime, func, Enum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
import enum
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AlertType(str, enum.Enum):
|
||||||
|
PRICE_ABOVE = "price_above"
|
||||||
|
PRICE_BELOW = "price_below"
|
||||||
|
CHANGE_PCT_ABOVE = "change_pct_above"
|
||||||
|
CHANGE_PCT_BELOW = "change_pct_below"
|
||||||
|
|
||||||
|
|
||||||
|
class Alert(Base):
|
||||||
|
__tablename__ = "alerts"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
symbol: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
alert_type: Mapped[AlertType] = mapped_column(Enum(AlertType), nullable=False)
|
||||||
|
threshold: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
triggered: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
triggered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship(back_populates="alerts")
|
||||||
22
backend/app/models/user.py
Normal file
22
backend/app/models/user.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, Boolean, DateTime, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
|
||||||
|
email: Mapped[str] = mapped_column(String(100), unique=True, index=True, nullable=False)
|
||||||
|
hashed_password: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
watchlist: Mapped[list["Watchlist"]] = relationship(back_populates="user", lazy="select")
|
||||||
|
alerts: Mapped[list["Alert"]] = relationship(back_populates="user", lazy="select")
|
||||||
18
backend/app/models/watchlist.py
Normal file
18
backend/app/models/watchlist.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, Integer, ForeignKey, DateTime, func, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Watchlist(Base):
|
||||||
|
__tablename__ = "watchlist"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "symbol", name="uq_user_symbol"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
symbol: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship(back_populates="watchlist")
|
||||||
1
backend/app/schemas/__init__.py
Normal file
1
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
47
backend/app/schemas/auth.py
Normal file
47
backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr, field_validator
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@field_validator("username")
|
||||||
|
@classmethod
|
||||||
|
def username_alphanumeric(cls, v: str) -> str:
|
||||||
|
if not re.match(r"^[a-zA-Z0-9_]{3,20}$", v):
|
||||||
|
raise ValueError("用户名只能包含字母、数字、下划线,长度3-20位")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("password")
|
||||||
|
@classmethod
|
||||||
|
def password_min_length(cls, v: str) -> str:
|
||||||
|
if len(v) < 6:
|
||||||
|
raise ValueError("密码至少6位")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
is_active: bool
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
91
backend/app/schemas/stock.py
Normal file
91
backend/app/schemas/stock.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class StockQuote(BaseModel):
|
||||||
|
symbol: str
|
||||||
|
name: str
|
||||||
|
price: float
|
||||||
|
change: float
|
||||||
|
change_pct: float
|
||||||
|
open: float
|
||||||
|
high: float
|
||||||
|
low: float
|
||||||
|
prev_close: float
|
||||||
|
volume: float
|
||||||
|
amount: float
|
||||||
|
market_cap: Optional[float] = None
|
||||||
|
pe_ratio: Optional[float] = None
|
||||||
|
sector: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StockSearchResult(BaseModel):
|
||||||
|
symbol: str
|
||||||
|
name: str
|
||||||
|
market: str
|
||||||
|
|
||||||
|
|
||||||
|
class KLineBar(BaseModel):
|
||||||
|
date: str
|
||||||
|
open: float
|
||||||
|
high: float
|
||||||
|
low: float
|
||||||
|
close: float
|
||||||
|
volume: float
|
||||||
|
amount: Optional[float] = None
|
||||||
|
change_pct: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class IntraDayBar(BaseModel):
|
||||||
|
time: str
|
||||||
|
price: float
|
||||||
|
volume: float
|
||||||
|
amount: Optional[float] = None
|
||||||
|
avg_price: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MarketOverview(BaseModel):
|
||||||
|
index_code: str
|
||||||
|
index_name: str
|
||||||
|
current: float
|
||||||
|
change: float
|
||||||
|
change_pct: float
|
||||||
|
|
||||||
|
|
||||||
|
class SectorData(BaseModel):
|
||||||
|
sector: str
|
||||||
|
change_pct: float
|
||||||
|
stocks: list[StockQuote] = []
|
||||||
|
|
||||||
|
|
||||||
|
class WatchlistItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
symbol: str
|
||||||
|
name: str
|
||||||
|
sort_order: int
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class WatchlistAddRequest(BaseModel):
|
||||||
|
symbol: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class AlertCreate(BaseModel):
|
||||||
|
symbol: str
|
||||||
|
name: str
|
||||||
|
alert_type: str
|
||||||
|
threshold: float
|
||||||
|
|
||||||
|
|
||||||
|
class AlertOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
symbol: str
|
||||||
|
name: str
|
||||||
|
alert_type: str
|
||||||
|
threshold: float
|
||||||
|
is_active: bool
|
||||||
|
triggered: bool
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
332
backend/app/services/stock_service.py
Normal file
332
backend/app/services/stock_service.py
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
"""
|
||||||
|
Stock data service — wraps AKShare with Redis caching.
|
||||||
|
All AKShare calls are blocking I/O; run them in a thread pool via asyncio.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Optional
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
try:
|
||||||
|
import akshare as ak
|
||||||
|
AK_AVAILABLE = True
|
||||||
|
except Exception:
|
||||||
|
AK_AVAILABLE = False
|
||||||
|
logger.warning("AKShare not available, using mock data")
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _run_sync(fn, *args, **kwargs):
|
||||||
|
"""Run a sync blocking function in the default thread pool."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
return loop.run_in_executor(None, lambda: fn(*args, **kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
# ── market overview ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
MAJOR_INDICES = [
|
||||||
|
("sh000001", "上证指数"),
|
||||||
|
("sz399001", "深证成指"),
|
||||||
|
("sz399006", "创业板指"),
|
||||||
|
("sh000688", "科创50"),
|
||||||
|
("sh000300", "沪深300"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_market_overview() -> list[dict]:
|
||||||
|
if not AK_AVAILABLE:
|
||||||
|
return _mock_market_overview()
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = await _run_sync(ak.stock_zh_index_spot_em)
|
||||||
|
result = []
|
||||||
|
code_map = {code: name for code, name in MAJOR_INDICES}
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
code = str(row.get("代码", ""))
|
||||||
|
if code in code_map:
|
||||||
|
result.append({
|
||||||
|
"index_code": code,
|
||||||
|
"index_name": code_map[code],
|
||||||
|
"current": float(row.get("最新价", 0)),
|
||||||
|
"change": float(row.get("涨跌额", 0)),
|
||||||
|
"change_pct": float(row.get("涨跌幅", 0)),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"get_market_overview error: {e}")
|
||||||
|
return _mock_market_overview()
|
||||||
|
|
||||||
|
|
||||||
|
# ── real-time quotes (A-share spot) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_all_stocks_spot() -> list[dict]:
|
||||||
|
"""All A-share real-time quotes — used for heatmap."""
|
||||||
|
if not AK_AVAILABLE:
|
||||||
|
return _mock_heatmap_data()
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = await _run_sync(ak.stock_zh_a_spot_em)
|
||||||
|
result = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
pct = float(row.get("涨跌幅", 0) or 0)
|
||||||
|
result.append({
|
||||||
|
"symbol": str(row.get("代码", "")),
|
||||||
|
"name": str(row.get("名称", "")),
|
||||||
|
"price": float(row.get("最新价", 0) or 0),
|
||||||
|
"change": float(row.get("涨跌额", 0) or 0),
|
||||||
|
"change_pct": pct,
|
||||||
|
"open": float(row.get("今开", 0) or 0),
|
||||||
|
"high": float(row.get("最高", 0) or 0),
|
||||||
|
"low": float(row.get("最低", 0) or 0),
|
||||||
|
"prev_close": float(row.get("昨收", 0) or 0),
|
||||||
|
"volume": float(row.get("成交量", 0) or 0),
|
||||||
|
"amount": float(row.get("成交额", 0) or 0),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"get_all_stocks_spot error: {e}")
|
||||||
|
return _mock_heatmap_data()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_stock_quote(symbol: str) -> Optional[dict]:
|
||||||
|
"""Single stock real-time quote."""
|
||||||
|
all_stocks = await get_all_stocks_spot()
|
||||||
|
for s in all_stocks:
|
||||||
|
if s["symbol"] == symbol:
|
||||||
|
return s
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── K-line data ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
PERIOD_MAP = {
|
||||||
|
"daily": "daily",
|
||||||
|
"weekly": "weekly",
|
||||||
|
"monthly": "monthly",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_kline(symbol: str, period: str = "daily", adjust: str = "qfq", limit: int = 250) -> list[dict]:
|
||||||
|
"""
|
||||||
|
period: daily | weekly | monthly
|
||||||
|
adjust: qfq (前复权) | hfq (后复权) | "" (不复权)
|
||||||
|
"""
|
||||||
|
if not AK_AVAILABLE:
|
||||||
|
return _mock_kline(limit)
|
||||||
|
|
||||||
|
ak_period = PERIOD_MAP.get(period, "daily")
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = await _run_sync(
|
||||||
|
ak.stock_zh_a_hist,
|
||||||
|
symbol=symbol,
|
||||||
|
period=ak_period,
|
||||||
|
adjust=adjust,
|
||||||
|
)
|
||||||
|
df = df.tail(limit)
|
||||||
|
result = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
result.append({
|
||||||
|
"date": str(row.get("日期", "")),
|
||||||
|
"open": float(row.get("开盘", 0)),
|
||||||
|
"high": float(row.get("最高", 0)),
|
||||||
|
"low": float(row.get("最低", 0)),
|
||||||
|
"close": float(row.get("收盘", 0)),
|
||||||
|
"volume": float(row.get("成交量", 0)),
|
||||||
|
"amount": float(row.get("成交额", 0)),
|
||||||
|
"change_pct": float(row.get("涨跌幅", 0)),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"get_kline {symbol} {period} error: {e}")
|
||||||
|
return _mock_kline(limit)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_intraday(symbol: str) -> list[dict]:
|
||||||
|
"""Today's minute-level data."""
|
||||||
|
if not AK_AVAILABLE:
|
||||||
|
return _mock_intraday()
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = await _run_sync(ak.stock_zh_a_hist_min_em, symbol=symbol, period="1", adjust="")
|
||||||
|
result = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
result.append({
|
||||||
|
"time": str(row.get("时间", "")),
|
||||||
|
"price": float(row.get("收盘", 0)),
|
||||||
|
"volume": float(row.get("成交量", 0)),
|
||||||
|
"amount": float(row.get("成交额", 0)),
|
||||||
|
"avg_price": float(row.get("均价", 0) or 0),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"get_intraday {symbol} error: {e}")
|
||||||
|
return _mock_intraday()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_five_day(symbol: str) -> list[dict]:
|
||||||
|
"""5-day minute-level data."""
|
||||||
|
if not AK_AVAILABLE:
|
||||||
|
return _mock_intraday(days=5)
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = await _run_sync(ak.stock_zh_a_hist_min_em, symbol=symbol, period="1", adjust="")
|
||||||
|
result = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
result.append({
|
||||||
|
"time": str(row.get("时间", "")),
|
||||||
|
"price": float(row.get("收盘", 0)),
|
||||||
|
"volume": float(row.get("成交量", 0)),
|
||||||
|
"amount": float(row.get("成交额", 0) or 0),
|
||||||
|
"avg_price": float(row.get("均价", 0) or 0),
|
||||||
|
})
|
||||||
|
return result[-5 * 240:]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"get_five_day {symbol} error: {e}")
|
||||||
|
return _mock_intraday(days=5)
|
||||||
|
|
||||||
|
|
||||||
|
# ── search ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _get_stock_list_cached() -> list[dict]:
|
||||||
|
"""Cache the full stock list in memory (refreshed on process restart)."""
|
||||||
|
if not AK_AVAILABLE:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
import akshare as ak
|
||||||
|
df = ak.stock_info_a_code_name()
|
||||||
|
return [{"symbol": str(r["code"]), "name": str(r["name"]), "market": "A股"} for _, r in df.iterrows()]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"_get_stock_list_cached error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def search_stocks(query: str, limit: int = 20) -> list[dict]:
|
||||||
|
stock_list = await _run_sync(_get_stock_list_cached)
|
||||||
|
query = query.strip().lower()
|
||||||
|
results = [
|
||||||
|
s for s in stock_list
|
||||||
|
if query in s["symbol"].lower() or query in s["name"].lower()
|
||||||
|
]
|
||||||
|
return results[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
# ── sector data for heatmap ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_sector_spot() -> list[dict]:
|
||||||
|
"""Board/sector change pct for heatmap grouping."""
|
||||||
|
if not AK_AVAILABLE:
|
||||||
|
return _mock_sectors()
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = await _run_sync(ak.stock_board_industry_name_em)
|
||||||
|
result = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
result.append({
|
||||||
|
"sector": str(row.get("板块名称", "")),
|
||||||
|
"change_pct": float(row.get("涨跌幅", 0) or 0),
|
||||||
|
"volume": float(row.get("成交量", 0) or 0),
|
||||||
|
"amount": float(row.get("成交额", 0) or 0),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"get_sector_spot error: {e}")
|
||||||
|
return _mock_sectors()
|
||||||
|
|
||||||
|
|
||||||
|
# ── mock data (fallback when market is closed or AKShare unavailable) ─────────
|
||||||
|
|
||||||
|
import random
|
||||||
|
import math
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_market_overview() -> list[dict]:
|
||||||
|
return [
|
||||||
|
{"index_code": "sh000001", "index_name": "上证指数", "current": 3312.46, "change": -28.84, "change_pct": -0.86},
|
||||||
|
{"index_code": "sz399001", "index_name": "深证成指", "current": 10573.99, "change": -93.16, "change_pct": -0.87},
|
||||||
|
{"index_code": "sz399006", "index_name": "创业板指", "current": 2105.37, "change": -18.42, "change_pct": -0.87},
|
||||||
|
{"index_code": "sh000688", "index_name": "科创50", "current": 968.12, "change": -9.56, "change_pct": -0.98},
|
||||||
|
{"index_code": "sh000300", "index_name": "沪深300", "current": 3843.20, "change": -30.11, "change_pct": -0.78},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_heatmap_data() -> list[dict]:
|
||||||
|
sectors = ["银行", "电力设备", "食品饮料", "医药生物", "电子", "汽车", "非银金融", "计算机", "有色金属", "化工"]
|
||||||
|
stocks = []
|
||||||
|
for i in range(80):
|
||||||
|
pct = round(random.uniform(-5, 5), 2)
|
||||||
|
sector = sectors[i % len(sectors)]
|
||||||
|
stocks.append({
|
||||||
|
"symbol": f"{600000 + i:06d}",
|
||||||
|
"name": f"测试股票{i+1:02d}",
|
||||||
|
"price": round(random.uniform(5, 100), 2),
|
||||||
|
"change": round(pct * 0.1, 2),
|
||||||
|
"change_pct": pct,
|
||||||
|
"open": round(random.uniform(5, 100), 2),
|
||||||
|
"high": round(random.uniform(5, 100), 2),
|
||||||
|
"low": round(random.uniform(5, 100), 2),
|
||||||
|
"prev_close": round(random.uniform(5, 100), 2),
|
||||||
|
"volume": random.randint(100000, 10000000),
|
||||||
|
"amount": random.randint(1000000, 100000000),
|
||||||
|
"sector": sector,
|
||||||
|
})
|
||||||
|
return stocks
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_kline(limit: int = 250) -> list[dict]:
|
||||||
|
bars = []
|
||||||
|
price = 20.0
|
||||||
|
today = date.today()
|
||||||
|
for i in range(limit):
|
||||||
|
d = today - timedelta(days=limit - i)
|
||||||
|
pct = random.uniform(-0.05, 0.05)
|
||||||
|
close = round(price * (1 + pct), 2)
|
||||||
|
high = round(max(price, close) * random.uniform(1.0, 1.03), 2)
|
||||||
|
low = round(min(price, close) * random.uniform(0.97, 1.0), 2)
|
||||||
|
bars.append({
|
||||||
|
"date": d.isoformat(),
|
||||||
|
"open": round(price, 2),
|
||||||
|
"high": high,
|
||||||
|
"low": low,
|
||||||
|
"close": close,
|
||||||
|
"volume": random.randint(500000, 5000000),
|
||||||
|
"amount": random.randint(5000000, 50000000),
|
||||||
|
"change_pct": round(pct * 100, 2),
|
||||||
|
})
|
||||||
|
price = close
|
||||||
|
return bars
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_intraday(days: int = 1) -> list[dict]:
|
||||||
|
bars = []
|
||||||
|
price = 20.0
|
||||||
|
for d in range(days):
|
||||||
|
for minute in range(240):
|
||||||
|
h = 9 + minute // 60
|
||||||
|
m = (minute % 60) + (30 if d == 0 and minute < 60 else 0)
|
||||||
|
if h == 11 and m >= 30:
|
||||||
|
continue
|
||||||
|
pct = random.uniform(-0.01, 0.01)
|
||||||
|
price = round(price * (1 + pct), 2)
|
||||||
|
bars.append({
|
||||||
|
"time": f"2026-06-0{d+1} {h:02d}:{m % 60:02d}",
|
||||||
|
"price": price,
|
||||||
|
"volume": random.randint(10000, 500000),
|
||||||
|
"amount": random.randint(100000, 5000000),
|
||||||
|
"avg_price": price,
|
||||||
|
})
|
||||||
|
return bars
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_sectors() -> list[dict]:
|
||||||
|
sectors = [
|
||||||
|
"银行", "电力设备", "食品饮料", "医药生物", "电子",
|
||||||
|
"汽车", "非银金融", "计算机", "有色金属", "化工",
|
||||||
|
"机械设备", "建筑材料", "传媒", "房地产", "交通运输",
|
||||||
|
]
|
||||||
|
return [{"sector": s, "change_pct": round(random.uniform(-3, 3), 2), "volume": 0, "amount": 0}
|
||||||
|
for s in sectors]
|
||||||
1
backend/app/websocket/__init__.py
Normal file
1
backend/app/websocket/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
58
backend/app/websocket/manager.py
Normal file
58
backend/app/websocket/manager.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
WebSocket connection manager — broadcasts real-time stock quotes to subscribers.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from collections import defaultdict
|
||||||
|
from fastapi import WebSocket
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionManager:
|
||||||
|
def __init__(self):
|
||||||
|
# symbol -> set of WebSocket connections
|
||||||
|
self._subs: dict[str, set[WebSocket]] = defaultdict(set)
|
||||||
|
self._all: set[WebSocket] = set()
|
||||||
|
|
||||||
|
async def connect(self, ws: WebSocket, symbol: str | None = None):
|
||||||
|
await ws.accept()
|
||||||
|
self._all.add(ws)
|
||||||
|
if symbol:
|
||||||
|
self._subs[symbol].add(ws)
|
||||||
|
logger.info(f"WS connected symbol={symbol}, total={len(self._all)}")
|
||||||
|
|
||||||
|
def disconnect(self, ws: WebSocket, symbol: str | None = None):
|
||||||
|
self._all.discard(ws)
|
||||||
|
if symbol:
|
||||||
|
self._subs[symbol].discard(ws)
|
||||||
|
else:
|
||||||
|
for s in list(self._subs.keys()):
|
||||||
|
self._subs[s].discard(ws)
|
||||||
|
logger.info(f"WS disconnected, total={len(self._all)}")
|
||||||
|
|
||||||
|
async def broadcast_quote(self, symbol: str, data: dict):
|
||||||
|
"""Send quote update to all subscribers of a specific symbol."""
|
||||||
|
message = json.dumps({"type": "quote", "symbol": symbol, "data": data})
|
||||||
|
dead = set()
|
||||||
|
for ws in list(self._subs.get(symbol, [])):
|
||||||
|
try:
|
||||||
|
await ws.send_text(message)
|
||||||
|
except Exception:
|
||||||
|
dead.add(ws)
|
||||||
|
for ws in dead:
|
||||||
|
self.disconnect(ws, symbol)
|
||||||
|
|
||||||
|
async def broadcast_all(self, data: list[dict]):
|
||||||
|
"""Broadcast heatmap snapshot to all connected clients."""
|
||||||
|
message = json.dumps({"type": "heatmap", "data": data})
|
||||||
|
dead = set()
|
||||||
|
for ws in list(self._all):
|
||||||
|
try:
|
||||||
|
await ws.send_text(message)
|
||||||
|
except Exception:
|
||||||
|
dead.add(ws)
|
||||||
|
for ws in dead:
|
||||||
|
self.disconnect(ws)
|
||||||
|
|
||||||
|
|
||||||
|
manager = ConnectionManager()
|
||||||
1
backend/celery_app/__init__.py
Normal file
1
backend/celery_app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
backend/celery_app/tasks/__init__.py
Normal file
1
backend/celery_app/tasks/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
36
backend/celery_app/tasks/market_tasks.py
Normal file
36
backend/celery_app/tasks/market_tasks.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import redis as sync_redis
|
||||||
|
from celery_app.worker import app
|
||||||
|
from app.core.config import settings
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_redis():
|
||||||
|
return sync_redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(name="celery_app.tasks.market_tasks.refresh_heatmap_cache")
|
||||||
|
def refresh_heatmap_cache():
|
||||||
|
"""Pull all A-share spot quotes and push to Redis cache."""
|
||||||
|
try:
|
||||||
|
from app.services.stock_service import get_all_stocks_spot
|
||||||
|
data = asyncio.run(get_all_stocks_spot())
|
||||||
|
r = _sync_redis()
|
||||||
|
r.setex("market:heatmap", 60, json.dumps(data))
|
||||||
|
logger.info(f"Heatmap cache refreshed: {len(data)} stocks")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"refresh_heatmap_cache error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(name="celery_app.tasks.market_tasks.refresh_market_overview")
|
||||||
|
def refresh_market_overview():
|
||||||
|
"""Pull major index data and push to Redis cache."""
|
||||||
|
try:
|
||||||
|
from app.services.stock_service import get_market_overview
|
||||||
|
data = asyncio.run(get_market_overview())
|
||||||
|
r = _sync_redis()
|
||||||
|
r.setex("market:overview", 120, json.dumps(data))
|
||||||
|
logger.info(f"Market overview refreshed: {len(data)} indices")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"refresh_market_overview error: {e}")
|
||||||
31
backend/celery_app/worker.py
Normal file
31
backend/celery_app/worker.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from celery import Celery
|
||||||
|
from celery.schedules import crontab
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
app = Celery(
|
||||||
|
"stock_worker",
|
||||||
|
broker=os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/1"),
|
||||||
|
backend=os.getenv("REDIS_URL", "redis://localhost:6379/0"),
|
||||||
|
include=["celery_app.tasks.market_tasks"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.conf.timezone = "Asia/Shanghai"
|
||||||
|
app.conf.enable_utc = False
|
||||||
|
|
||||||
|
# Windows does not support the default prefork pool (fork syscall unavailable)
|
||||||
|
if sys.platform == "win32":
|
||||||
|
app.conf.worker_pool = "solo"
|
||||||
|
|
||||||
|
app.conf.beat_schedule = {
|
||||||
|
# Refresh heatmap cache every 30s during trading hours
|
||||||
|
"refresh-heatmap-30s": {
|
||||||
|
"task": "celery_app.tasks.market_tasks.refresh_heatmap_cache",
|
||||||
|
"schedule": 30.0,
|
||||||
|
},
|
||||||
|
# Refresh index data every minute
|
||||||
|
"refresh-indices-1m": {
|
||||||
|
"task": "celery_app.tasks.market_tasks.refresh_market_overview",
|
||||||
|
"schedule": 60.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
34
backend/requirements.txt
Normal file
34
backend/requirements.txt
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Web framework
|
||||||
|
fastapi==0.115.5
|
||||||
|
uvicorn[standard]==0.32.1
|
||||||
|
python-multipart==0.0.20
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlalchemy==2.0.36
|
||||||
|
asyncpg==0.30.0
|
||||||
|
alembic==1.14.0
|
||||||
|
|
||||||
|
# Redis & Celery
|
||||||
|
redis==5.2.1
|
||||||
|
celery==5.4.0
|
||||||
|
celery[redis]==5.4.0
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
|
||||||
|
# Stock data
|
||||||
|
akshare==1.18.64
|
||||||
|
|
||||||
|
# HTTP client
|
||||||
|
httpx==0.28.1
|
||||||
|
aiohttp==3.11.11
|
||||||
|
|
||||||
|
# Validation & settings
|
||||||
|
pydantic==2.10.3
|
||||||
|
pydantic-settings==2.7.0
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
loguru==0.7.3
|
||||||
|
tenacity==9.0.0
|
||||||
32
docker-compose.dev.yml
Normal file
32
docker-compose.dev.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# 本地开发专用:只启动基础设施(PostgreSQL + Redis)
|
||||||
|
# 前后端在宿主机直接运行,无需 Docker
|
||||||
|
#
|
||||||
|
# 使用方式:
|
||||||
|
# docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: stock_postgres_dev
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: stock
|
||||||
|
POSTGRES_PASSWORD: stockpass123
|
||||||
|
POSTGRES_DB: stockdb
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_dev_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: stock_redis_dev
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_dev_data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_dev_data:
|
||||||
|
redis_dev_data:
|
||||||
111
docker-compose.yml
Normal file
111
docker-compose.yml
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: stock_postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-stock}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-stockpass}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-stockdb}
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U stock -d stockdb"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: stock_redis
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: stock_backend
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-stock}:${POSTGRES_PASSWORD:-stockpass}@postgres:5432/${POSTGRES_DB:-stockdb}
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
celery_worker:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: stock_celery_worker
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-stock}:${POSTGRES_PASSWORD:-stockpass}@postgres:5432/${POSTGRES_DB:-stockdb}
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
CELERY_BROKER_URL: redis://redis:6379/1
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
command: celery -A celery_app.worker worker -l info -c 2
|
||||||
|
|
||||||
|
celery_beat:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: stock_celery_beat
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-stock}:${POSTGRES_PASSWORD:-stockpass}@postgres:5432/${POSTGRES_DB:-stockdb}
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
CELERY_BROKER_URL: redis://redis:6379/1
|
||||||
|
depends_on:
|
||||||
|
- celery_worker
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
command: celery -A celery_app.worker beat -l info --scheduler celery.beat:PersistentScheduler
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: stock_frontend
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: stock_nginx
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
11
frontend/Dockerfile
Normal file
11
frontend/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>股票行情平台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
frontend/nginx.conf
Normal file
15
frontend/nginx.conf
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
4986
frontend/package-lock.json
generated
Normal file
4986
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "stock-platform",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint src --ext ts,tsx"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.28.0",
|
||||||
|
"antd": "^5.22.2",
|
||||||
|
"@ant-design/icons": "^5.5.2",
|
||||||
|
"@ant-design/pro-components": "^2.8.2",
|
||||||
|
"echarts": "^5.5.1",
|
||||||
|
"echarts-for-react": "^3.0.2",
|
||||||
|
"lightweight-charts": "^4.2.0",
|
||||||
|
"zustand": "^5.0.2",
|
||||||
|
"@tanstack/react-query": "^5.62.7",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"numeral": "^2.0.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@types/numeral": "^2.0.5",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^6.0.3",
|
||||||
|
"eslint": "^9.15.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
45
frontend/src/App.tsx
Normal file
45
frontend/src/App.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import MainLayout from "@/components/Layout/MainLayout";
|
||||||
|
import LoginPage from "@/pages/Login/LoginPage";
|
||||||
|
import HomePage from "@/pages/Home/HomePage";
|
||||||
|
import StockDetailPage from "@/pages/StockDetail/StockDetailPage";
|
||||||
|
import WatchlistPage from "@/pages/Watchlist/WatchlistPage";
|
||||||
|
import AlertsPage from "@/pages/Alerts/AlertsPage";
|
||||||
|
|
||||||
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const fetchMe = useAuthStore((s) => s.fetchMe);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMe();
|
||||||
|
}, [fetchMe]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<MainLayout />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<HomePage />} />
|
||||||
|
<Route path="stock/:symbol" element={<StockDetailPage />} />
|
||||||
|
<Route path="watchlist" element={<WatchlistPage />} />
|
||||||
|
<Route path="alerts" element={<AlertsPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
frontend/src/components/Charts/HeatMap.tsx
Normal file
141
frontend/src/components/Charts/HeatMap.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import ReactECharts from "echarts-for-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import type { StockQuote } from "@/types";
|
||||||
|
|
||||||
|
interface HeatMapProps {
|
||||||
|
data: StockQuote[];
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColor(pct: number): string {
|
||||||
|
if (pct >= 9) return "#8b0000";
|
||||||
|
if (pct >= 6) return "#c0392b";
|
||||||
|
if (pct >= 3) return "#e74c3c";
|
||||||
|
if (pct >= 1) return "#e57373";
|
||||||
|
if (pct > 0) return "#ef9a9a";
|
||||||
|
if (pct === 0) return "#4a4a4a";
|
||||||
|
if (pct > -1) return "#80cbc4";
|
||||||
|
if (pct > -3) return "#26a69a";
|
||||||
|
if (pct > -6) return "#00897b";
|
||||||
|
if (pct > -9) return "#00695c";
|
||||||
|
return "#004d40";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeatMap({ data, loading }: HeatMapProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Group by sector
|
||||||
|
const sectorMap: Record<string, StockQuote[]> = {};
|
||||||
|
for (const stock of data) {
|
||||||
|
const sector = stock.sector || "其他";
|
||||||
|
if (!sectorMap[sector]) sectorMap[sector] = [];
|
||||||
|
sectorMap[sector].push(stock);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build ECharts treemap structure
|
||||||
|
const treeData = Object.entries(sectorMap)
|
||||||
|
.sort((a, b) => b[1].length - a[1].length)
|
||||||
|
.slice(0, 20)
|
||||||
|
.map(([sector, stocks]) => ({
|
||||||
|
name: sector,
|
||||||
|
value: stocks.reduce((sum, s) => sum + Math.abs(s.amount || s.volume), 0),
|
||||||
|
children: stocks
|
||||||
|
.sort((a, b) => Math.abs(b.amount || 0) - Math.abs(a.amount || 0))
|
||||||
|
.slice(0, 30)
|
||||||
|
.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
value: Math.abs(s.amount || s.volume) || 1,
|
||||||
|
symbol: s.symbol,
|
||||||
|
change_pct: s.change_pct,
|
||||||
|
price: s.price,
|
||||||
|
itemStyle: { color: getColor(s.change_pct) },
|
||||||
|
label: {
|
||||||
|
formatter: () => `${s.name}\n${s.change_pct >= 0 ? "+" : ""}${s.change_pct.toFixed(2)}%`,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
tooltip: {
|
||||||
|
trigger: "item",
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const d = params.data;
|
||||||
|
if (!d.symbol) return d.name;
|
||||||
|
return `
|
||||||
|
<div style="min-width:120px">
|
||||||
|
<div style="font-weight:600;margin-bottom:4px">${d.name}(${d.symbol})</div>
|
||||||
|
<div>当前价:<b>${d.price?.toFixed(2)}</b></div>
|
||||||
|
<div style="color:${d.change_pct >= 0 ? "#f03e3e" : "#00b368"}">
|
||||||
|
涨跌幅:${d.change_pct >= 0 ? "+" : ""}${d.change_pct?.toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "treemap",
|
||||||
|
data: treeData,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
roam: false,
|
||||||
|
nodeClick: "zoomToNode",
|
||||||
|
breadcrumb: { show: false },
|
||||||
|
levels: [
|
||||||
|
{
|
||||||
|
// Sector level
|
||||||
|
itemStyle: {
|
||||||
|
borderColor: "#0d1117",
|
||||||
|
borderWidth: 2,
|
||||||
|
gapWidth: 2,
|
||||||
|
},
|
||||||
|
upperLabel: {
|
||||||
|
show: true,
|
||||||
|
color: "#e6edf3",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "bold",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.4)",
|
||||||
|
padding: [2, 6],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Stock level
|
||||||
|
itemStyle: {
|
||||||
|
borderColor: "#0d1117",
|
||||||
|
borderWidth: 1,
|
||||||
|
gapWidth: 1,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: "bold",
|
||||||
|
align: "center",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEvents = {
|
||||||
|
click: (params: any) => {
|
||||||
|
if (params.data?.symbol) {
|
||||||
|
navigate(`/stock/${params.data.symbol}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactECharts
|
||||||
|
option={option}
|
||||||
|
showLoading={loading}
|
||||||
|
loadingOption={{ color: "#1677ff", textColor: "#8b949e", maskColor: "#0d111788" }}
|
||||||
|
onEvents={onEvents}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
notMerge
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
frontend/src/components/Charts/IntraDayChart.tsx
Normal file
175
frontend/src/components/Charts/IntraDayChart.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import ReactECharts from "echarts-for-react";
|
||||||
|
import type { IntraDayBar } from "@/types";
|
||||||
|
|
||||||
|
interface IntraDayChartProps {
|
||||||
|
data: IntraDayBar[];
|
||||||
|
prevClose?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IntraDayChart({ data, prevClose, height = 380 }: IntraDayChartProps) {
|
||||||
|
if (data.length === 0) return null;
|
||||||
|
|
||||||
|
const times = data.map((d) => d.time.slice(-5));
|
||||||
|
const prices = data.map((d) => d.price);
|
||||||
|
const volumes = data.map((d) => d.volume);
|
||||||
|
const avgPrices = data.map((d) => d.avg_price || d.price);
|
||||||
|
|
||||||
|
const minPrice = Math.min(...prices) * 0.998;
|
||||||
|
const maxPrice = Math.max(...prices) * 1.002;
|
||||||
|
|
||||||
|
const baseline = prevClose || prices[0];
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
animation: false,
|
||||||
|
tooltip: {
|
||||||
|
trigger: "axis",
|
||||||
|
axisPointer: { type: "cross", lineStyle: { color: "#484f58" } },
|
||||||
|
backgroundColor: "#161b22",
|
||||||
|
borderColor: "#30363d",
|
||||||
|
textStyle: { color: "#e6edf3", fontSize: 12 },
|
||||||
|
formatter: (params: any[]) => {
|
||||||
|
const time = params[0]?.axisValue;
|
||||||
|
const price = params.find((p) => p.seriesName === "价格")?.data;
|
||||||
|
const avg = params.find((p) => p.seriesName === "均价")?.data;
|
||||||
|
const vol = params.find((p) => p.seriesName === "成交量")?.data;
|
||||||
|
const pct = price != null && baseline ? (((price - baseline) / baseline) * 100).toFixed(2) : "-";
|
||||||
|
return `
|
||||||
|
<div style="min-width:140px;padding:4px">
|
||||||
|
<div style="color:#8b949e;margin-bottom:4px">${time}</div>
|
||||||
|
<div>价格:<b style="color:${Number(pct) >= 0 ? "#f03e3e" : "#00b368"}">${price?.toFixed(2)}</b></div>
|
||||||
|
<div>均价:<b>${avg?.toFixed(2)}</b></div>
|
||||||
|
<div>涨跌幅:<span style="color:${Number(pct) >= 0 ? "#f03e3e" : "#00b368"}">${Number(pct) >= 0 ? "+" : ""}${pct}%</span></div>
|
||||||
|
<div>成交量:<b>${((vol || 0) / 100).toFixed(0)}手</b></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisPointer: { link: [{ xAxisIndex: "all" }] },
|
||||||
|
grid: [
|
||||||
|
{ left: 60, right: 60, top: 12, bottom: 80, height: "60%" },
|
||||||
|
{ left: 60, right: 60, top: "72%", bottom: 24 },
|
||||||
|
],
|
||||||
|
xAxis: [
|
||||||
|
{
|
||||||
|
type: "category",
|
||||||
|
data: times,
|
||||||
|
gridIndex: 0,
|
||||||
|
axisLine: { lineStyle: { color: "#30363d" } },
|
||||||
|
axisLabel: { color: "#8b949e", fontSize: 11 },
|
||||||
|
splitLine: { show: false },
|
||||||
|
boundaryGap: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "category",
|
||||||
|
data: times,
|
||||||
|
gridIndex: 1,
|
||||||
|
axisLine: { lineStyle: { color: "#30363d" } },
|
||||||
|
axisLabel: { show: false },
|
||||||
|
splitLine: { show: false },
|
||||||
|
boundaryGap: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: "value",
|
||||||
|
min: minPrice,
|
||||||
|
max: maxPrice,
|
||||||
|
gridIndex: 0,
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisLabel: {
|
||||||
|
color: "#8b949e",
|
||||||
|
fontSize: 11,
|
||||||
|
formatter: (v: number) => v.toFixed(2),
|
||||||
|
},
|
||||||
|
splitLine: { lineStyle: { color: "#21262d" } },
|
||||||
|
position: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "value",
|
||||||
|
gridIndex: 0,
|
||||||
|
min: minPrice,
|
||||||
|
max: maxPrice,
|
||||||
|
axisLabel: {
|
||||||
|
color: "#8b949e",
|
||||||
|
fontSize: 11,
|
||||||
|
formatter: (v: number) => {
|
||||||
|
if (!baseline) return "";
|
||||||
|
const pct = ((v - baseline) / baseline) * 100;
|
||||||
|
return (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisLine: { show: false },
|
||||||
|
splitLine: { show: false },
|
||||||
|
position: "left",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "value",
|
||||||
|
gridIndex: 1,
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisLabel: { color: "#8b949e", fontSize: 10 },
|
||||||
|
splitLine: { lineStyle: { color: "#21262d" } },
|
||||||
|
position: "right",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "价格",
|
||||||
|
type: "line",
|
||||||
|
data: prices,
|
||||||
|
xAxisIndex: 0,
|
||||||
|
yAxisIndex: 0,
|
||||||
|
lineStyle: { color: "#1677ff", width: 1.5 },
|
||||||
|
symbol: "none",
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: "linear",
|
||||||
|
x: 0, y: 0, x2: 0, y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: "#1677ff33" },
|
||||||
|
{ offset: 1, color: "#1677ff00" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
markLine: baseline ? {
|
||||||
|
silent: true,
|
||||||
|
data: [{ yAxis: baseline }],
|
||||||
|
lineStyle: { color: "#484f58", type: "dashed" },
|
||||||
|
label: { show: false },
|
||||||
|
} : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "均价",
|
||||||
|
type: "line",
|
||||||
|
data: avgPrices,
|
||||||
|
xAxisIndex: 0,
|
||||||
|
yAxisIndex: 0,
|
||||||
|
lineStyle: { color: "#ffd700", width: 1, type: "dashed" },
|
||||||
|
symbol: "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "成交量",
|
||||||
|
type: "bar",
|
||||||
|
data: volumes,
|
||||||
|
xAxisIndex: 1,
|
||||||
|
yAxisIndex: 2,
|
||||||
|
itemStyle: {
|
||||||
|
color: (params: any) => {
|
||||||
|
const p = prices[params.dataIndex];
|
||||||
|
const prev = params.dataIndex > 0 ? prices[params.dataIndex - 1] : baseline;
|
||||||
|
return p >= (prev || baseline) ? "#f03e3e66" : "#00b36866";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactECharts
|
||||||
|
option={option}
|
||||||
|
style={{ width: "100%", height }}
|
||||||
|
notMerge
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
frontend/src/components/Charts/KLineChart.tsx
Normal file
112
frontend/src/components/Charts/KLineChart.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { createChart, ColorType, LineStyle, CrosshairMode } from "lightweight-charts";
|
||||||
|
import type { KLineBar } from "@/types";
|
||||||
|
|
||||||
|
interface KLineChartProps {
|
||||||
|
data: KLineBar[];
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KLineChart({ data, height = 420 }: KLineChartProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || data.length === 0) return;
|
||||||
|
|
||||||
|
const chart = createChart(containerRef.current, {
|
||||||
|
width: containerRef.current.clientWidth,
|
||||||
|
height,
|
||||||
|
layout: {
|
||||||
|
background: { type: ColorType.Solid, color: "#161b22" },
|
||||||
|
textColor: "#8b949e",
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
vertLines: { color: "#21262d" },
|
||||||
|
horzLines: { color: "#21262d" },
|
||||||
|
},
|
||||||
|
crosshair: {
|
||||||
|
mode: CrosshairMode.Normal,
|
||||||
|
vertLine: { color: "#484f58", labelBackgroundColor: "#21262d" },
|
||||||
|
horzLine: { color: "#484f58", labelBackgroundColor: "#21262d" },
|
||||||
|
},
|
||||||
|
rightPriceScale: {
|
||||||
|
borderColor: "#30363d",
|
||||||
|
},
|
||||||
|
timeScale: {
|
||||||
|
borderColor: "#30363d",
|
||||||
|
timeVisible: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Candlestick series
|
||||||
|
const candleSeries = chart.addCandlestickSeries({
|
||||||
|
upColor: "#f03e3e",
|
||||||
|
downColor: "#00b368",
|
||||||
|
borderUpColor: "#f03e3e",
|
||||||
|
borderDownColor: "#00b368",
|
||||||
|
wickUpColor: "#f03e3e",
|
||||||
|
wickDownColor: "#00b368",
|
||||||
|
});
|
||||||
|
|
||||||
|
const candleData = data.map((d) => ({
|
||||||
|
time: d.date as any,
|
||||||
|
open: d.open,
|
||||||
|
high: d.high,
|
||||||
|
low: d.low,
|
||||||
|
close: d.close,
|
||||||
|
}));
|
||||||
|
candleSeries.setData(candleData);
|
||||||
|
|
||||||
|
// Volume histogram
|
||||||
|
const volumeSeries = chart.addHistogramSeries({
|
||||||
|
color: "#26a69a",
|
||||||
|
priceFormat: { type: "volume" },
|
||||||
|
priceScaleId: "volume",
|
||||||
|
});
|
||||||
|
|
||||||
|
chart.priceScale("volume").applyOptions({
|
||||||
|
scaleMargins: { top: 0.8, bottom: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const volumeData = data.map((d) => ({
|
||||||
|
time: d.date as any,
|
||||||
|
value: d.volume,
|
||||||
|
color: d.close >= d.open ? "#f03e3e44" : "#00b36844",
|
||||||
|
}));
|
||||||
|
volumeSeries.setData(volumeData);
|
||||||
|
|
||||||
|
// MA lines
|
||||||
|
const maColors = { 5: "#ffd700", 10: "#ff69b4", 20: "#00bfff", 60: "#ffa500" };
|
||||||
|
for (const [period, color] of Object.entries(maColors)) {
|
||||||
|
const p = Number(period);
|
||||||
|
const maSeries = chart.addLineSeries({
|
||||||
|
color,
|
||||||
|
lineWidth: 1,
|
||||||
|
priceLineVisible: false,
|
||||||
|
lastValueVisible: false,
|
||||||
|
});
|
||||||
|
const maData = [];
|
||||||
|
for (let i = p - 1; i < data.length; i++) {
|
||||||
|
const avg = data.slice(i - p + 1, i + 1).reduce((s, d) => s + d.close, 0) / p;
|
||||||
|
maData.push({ time: data[i].date as any, value: parseFloat(avg.toFixed(3)) });
|
||||||
|
}
|
||||||
|
maSeries.setData(maData);
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.timeScale().fitContent();
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
chart.applyOptions({ width: containerRef.current.clientWidth });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
chart.remove();
|
||||||
|
};
|
||||||
|
}, [data, height]);
|
||||||
|
|
||||||
|
return <div ref={containerRef} style={{ width: "100%", height }} />;
|
||||||
|
}
|
||||||
250
frontend/src/components/Layout/MainLayout.tsx
Normal file
250
frontend/src/components/Layout/MainLayout.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { Outlet, useNavigate, useLocation, Link } from "react-router-dom";
|
||||||
|
import { Layout, Menu, Input, Button, Badge, Avatar, Dropdown, Space, Tag } from "antd";
|
||||||
|
import {
|
||||||
|
LineChartOutlined,
|
||||||
|
StarOutlined,
|
||||||
|
BellOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
HomeOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import { stockService } from "@/services/stocks";
|
||||||
|
import type { MarketIndex } from "@/types";
|
||||||
|
|
||||||
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
|
function IndexBar() {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["market-overview"],
|
||||||
|
queryFn: stockService.getMarketOverview,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space size={24} style={{ marginLeft: 24 }}>
|
||||||
|
{(data || []).slice(0, 3).map((idx: MarketIndex) => (
|
||||||
|
<span key={idx.index_code} style={{ fontSize: 12 }}>
|
||||||
|
<span style={{ color: "#8b949e", marginRight: 4 }}>{idx.index_name}</span>
|
||||||
|
<span style={{ color: idx.change_pct >= 0 ? "#f03e3e" : "#00b368", fontWeight: 600 }}>
|
||||||
|
{idx.current.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: idx.change_pct >= 0 ? "#f03e3e" : "#00b368",
|
||||||
|
marginLeft: 4,
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{idx.change_pct >= 0 ? "+" : ""}
|
||||||
|
{idx.change_pct.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MainLayout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
const [searchValue, setSearchValue] = useState("");
|
||||||
|
const [searchResults, setSearchResults] = useState<
|
||||||
|
{ symbol: string; name: string }[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const handleSearch = useCallback(async (value: string) => {
|
||||||
|
if (!value.trim()) return setSearchResults([]);
|
||||||
|
const results = await stockService.searchStocks(value);
|
||||||
|
setSearchResults(results.slice(0, 8));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedKey =
|
||||||
|
location.pathname === "/" ? "home"
|
||||||
|
: location.pathname.startsWith("/stock") ? "home"
|
||||||
|
: location.pathname.startsWith("/watchlist") ? "watchlist"
|
||||||
|
: location.pathname.startsWith("/alerts") ? "alerts"
|
||||||
|
: "home";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{ minHeight: "100vh", background: "#0d1117" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
background: "#161b22",
|
||||||
|
borderBottom: "1px solid #30363d",
|
||||||
|
padding: "0 16px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
height: 52,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
style={{
|
||||||
|
color: "#58a6ff",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 16,
|
||||||
|
textDecoration: "none",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
股票行情
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<IndexBar />
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索股票代码/名称..."
|
||||||
|
prefix={<SearchOutlined style={{ color: "#8b949e" }} />}
|
||||||
|
value={searchValue}
|
||||||
|
style={{ width: 220, background: "#0d1117", border: "1px solid #30363d" }}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchValue(e.target.value);
|
||||||
|
handleSearch(e.target.value);
|
||||||
|
}}
|
||||||
|
onPressEnter={() => {
|
||||||
|
const first = searchResults[0];
|
||||||
|
if (first) {
|
||||||
|
navigate(`/stock/${first.symbol}`);
|
||||||
|
setSearchValue("");
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "100%",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
background: "#161b22",
|
||||||
|
border: "1px solid #30363d",
|
||||||
|
borderRadius: 6,
|
||||||
|
zIndex: 200,
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{searchResults.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.symbol}
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/stock/${r.symbol}`);
|
||||||
|
setSearchValue("");
|
||||||
|
setSearchResults([]);
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
((e.currentTarget as HTMLDivElement).style.background = "#21262d")
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
((e.currentTarget as HTMLDivElement).style.background = "transparent")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span style={{ color: "#e6edf3", fontSize: 13 }}>{r.name}</span>
|
||||||
|
<Tag color="default" style={{ fontSize: 11 }}>{r.symbol}</Tag>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space style={{ marginLeft: 16 }}>
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "logout",
|
||||||
|
icon: <LogoutOutlined />,
|
||||||
|
label: "退出登录",
|
||||||
|
onClick: () => {
|
||||||
|
logout();
|
||||||
|
navigate("/login");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space style={{ cursor: "pointer", color: "#e6edf3" }}>
|
||||||
|
<Avatar size={28} icon={<UserOutlined />} style={{ background: "#1f6feb" }} />
|
||||||
|
<span style={{ fontSize: 13 }}>{user?.username}</span>
|
||||||
|
</Space>
|
||||||
|
</Dropdown>
|
||||||
|
</Space>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Layout style={{ marginTop: 52 }}>
|
||||||
|
{/* Sider */}
|
||||||
|
<Sider
|
||||||
|
width={180}
|
||||||
|
style={{
|
||||||
|
background: "#161b22",
|
||||||
|
borderRight: "1px solid #30363d",
|
||||||
|
position: "fixed",
|
||||||
|
top: 52,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[selectedKey]}
|
||||||
|
style={{ background: "transparent", border: "none", marginTop: 8 }}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: "home",
|
||||||
|
icon: <HomeOutlined />,
|
||||||
|
label: "大盘行情",
|
||||||
|
onClick: () => navigate("/"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "watchlist",
|
||||||
|
icon: <StarOutlined />,
|
||||||
|
label: "自选股",
|
||||||
|
onClick: () => navigate("/watchlist"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "alerts",
|
||||||
|
icon: <BellOutlined />,
|
||||||
|
label: "价格预警",
|
||||||
|
onClick: () => navigate("/alerts"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Content
|
||||||
|
style={{
|
||||||
|
marginLeft: 180,
|
||||||
|
padding: 16,
|
||||||
|
minHeight: "calc(100vh - 52px)",
|
||||||
|
background: "#0d1117",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/hooks/useWebSocket.ts
Normal file
48
frontend/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
type MessageHandler = (data: unknown) => void;
|
||||||
|
|
||||||
|
export function useWebSocket(url: string, onMessage: MessageHandler) {
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
const fullUrl = `${url}${token ? `?token=${token}` : ""}`;
|
||||||
|
const ws = new WebSocket(fullUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
onMessage(data);
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
reconnectTimer.current = setTimeout(connect, 3000) as ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
|
}, [url, onMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||||
|
wsRef.current?.close();
|
||||||
|
};
|
||||||
|
}, [connect]);
|
||||||
|
}
|
||||||
37
frontend/src/index.css
Normal file
37
frontend/src/index.css
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
background: #0d1117;
|
||||||
|
color: #e6edf3;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #161b22;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #30363d;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #484f58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.up-color {
|
||||||
|
color: #f03e3e;
|
||||||
|
}
|
||||||
|
.down-color {
|
||||||
|
color: #00b368;
|
||||||
|
}
|
||||||
|
.flat-color {
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
35
frontend/src/main.tsx
Normal file
35
frontend/src/main.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { ConfigProvider, theme } from "antd";
|
||||||
|
import zhCN from "antd/locale/zh_CN";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ConfigProvider
|
||||||
|
locale={zhCN}
|
||||||
|
theme={{
|
||||||
|
algorithm: theme.darkAlgorithm,
|
||||||
|
token: {
|
||||||
|
colorPrimary: "#1677ff",
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<App />
|
||||||
|
</ConfigProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
191
frontend/src/pages/Alerts/AlertsPage.tsx
Normal file
191
frontend/src/pages/Alerts/AlertsPage.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Table, Button, Card, Typography, Modal, Form, Input, Select, InputNumber,
|
||||||
|
Switch, Popconfirm, message, Tag, Empty,
|
||||||
|
} from "antd";
|
||||||
|
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { stockService } from "@/services/stocks";
|
||||||
|
import type { Alert } from "@/types";
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
const ALERT_TYPES = [
|
||||||
|
{ value: "price_above", label: "价格高于" },
|
||||||
|
{ value: "price_below", label: "价格低于" },
|
||||||
|
{ value: "change_pct_above", label: "涨幅超过" },
|
||||||
|
{ value: "change_pct_below", label: "跌幅超过" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AlertsPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const { data: alerts = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["alerts"],
|
||||||
|
queryFn: stockService.getAlerts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: stockService.createAlert,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["alerts"] });
|
||||||
|
message.success("预警已创建");
|
||||||
|
setModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
},
|
||||||
|
onError: () => message.error("创建失败"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: stockService.deleteAlert,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["alerts"] });
|
||||||
|
message.success("已删除");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: stockService.toggleAlert,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["alerts"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: "股票",
|
||||||
|
key: "stock",
|
||||||
|
render: (_: unknown, record: Alert) => (
|
||||||
|
<span>
|
||||||
|
<span style={{ color: "#e6edf3", fontWeight: 600 }}>{record.name}</span>
|
||||||
|
<Tag style={{ marginLeft: 8, fontSize: 11 }} color="default">
|
||||||
|
{record.symbol}
|
||||||
|
</Tag>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "预警类型",
|
||||||
|
dataIndex: "alert_type",
|
||||||
|
key: "alert_type",
|
||||||
|
render: (v: string) => {
|
||||||
|
const t = ALERT_TYPES.find((t) => t.value === v);
|
||||||
|
return <Tag color="blue">{t?.label || v}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "阈值",
|
||||||
|
dataIndex: "threshold",
|
||||||
|
key: "threshold",
|
||||||
|
render: (v: number, record: Alert) => (
|
||||||
|
<span style={{ color: "#e6edf3", fontWeight: 600 }}>
|
||||||
|
{v}
|
||||||
|
{record.alert_type.includes("pct") ? "%" : "元"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
key: "status",
|
||||||
|
render: (_: unknown, record: Alert) => {
|
||||||
|
if (record.triggered) return <Tag color="warning">已触发</Tag>;
|
||||||
|
return record.is_active ? <Tag color="success">监控中</Tag> : <Tag>已暂停</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "启用",
|
||||||
|
key: "active",
|
||||||
|
render: (_: unknown, record: Alert) => (
|
||||||
|
<Switch
|
||||||
|
checked={record.is_active}
|
||||||
|
size="small"
|
||||||
|
onChange={() => toggleMutation.mutate(record.id)}
|
||||||
|
disabled={record.triggered}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "action",
|
||||||
|
render: (_: unknown, record: Alert) => (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该预警?"
|
||||||
|
onConfirm={() => deleteMutation.mutate(record.id)}
|
||||||
|
okText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
danger
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<Title level={4} style={{ margin: 0, color: "#e6edf3" }}>
|
||||||
|
价格预警
|
||||||
|
</Title>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
>
|
||||||
|
新建预警
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
style={{ background: "#161b22", border: "1px solid #30363d" }}
|
||||||
|
bodyStyle={{ padding: 0 }}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={alerts}
|
||||||
|
loading={isLoading}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
locale={{ emptyText: <Empty description="暂无预警" /> }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="新建价格预警"
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
confirmLoading={createMutation.isPending}
|
||||||
|
okText="创建"
|
||||||
|
cancelText="取消"
|
||||||
|
styles={{ content: { background: "#161b22" }, header: { background: "#161b22" } }}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={(values) => createMutation.mutate(values)}
|
||||||
|
>
|
||||||
|
<Form.Item name="symbol" label="股票代码" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="如:600519" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="name" label="股票名称" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="如:贵州茅台" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="alert_type" label="预警类型" rules={[{ required: true }]}>
|
||||||
|
<Select options={ALERT_TYPES} placeholder="选择预警类型" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="threshold" label="阈值" rules={[{ required: true }]}>
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
placeholder="输入阈值(价格填元,涨跌幅填%数字)"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
frontend/src/pages/Home/HomePage.tsx
Normal file
153
frontend/src/pages/Home/HomePage.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Row, Col, Card, Tag, Spin, Space } from "antd";
|
||||||
|
import { SyncOutlined } from "@ant-design/icons";
|
||||||
|
import { stockService } from "@/services/stocks";
|
||||||
|
import { useWebSocket } from "@/hooks/useWebSocket";
|
||||||
|
import HeatMap from "@/components/Charts/HeatMap";
|
||||||
|
import type { StockQuote, MarketIndex } from "@/types";
|
||||||
|
|
||||||
|
function IndexCard({ idx }: { idx: MarketIndex }) {
|
||||||
|
const isUp = idx.change_pct > 0;
|
||||||
|
const isDown = idx.change_pct < 0;
|
||||||
|
const color = isUp ? "#f03e3e" : isDown ? "#00b368" : "#8b949e";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ background: "#161b22", border: "1px solid #30363d", borderRadius: 8 }}
|
||||||
|
bodyStyle={{ padding: "10px 14px" }}
|
||||||
|
>
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 12 }}>{idx.index_name}</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "baseline", gap: 8, marginTop: 2 }}>
|
||||||
|
<span style={{ color, fontSize: 20, fontWeight: 700 }}>{idx.current.toFixed(2)}</span>
|
||||||
|
<span style={{ color, fontSize: 12 }}>
|
||||||
|
{idx.change_pct >= 0 ? "+" : ""}{idx.change_pct.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
<span style={{ color, fontSize: 12 }}>
|
||||||
|
{idx.change >= 0 ? "+" : ""}{idx.change.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const [heatmapData, setHeatmapData] = useState<StockQuote[]>([]);
|
||||||
|
|
||||||
|
const { data: overview } = useQuery<MarketIndex[]>({
|
||||||
|
queryKey: ["market-overview"],
|
||||||
|
queryFn: stockService.getMarketOverview,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: initialHeatmap, isLoading: heatmapLoading } = useQuery<StockQuote[]>({
|
||||||
|
queryKey: ["market-heatmap"],
|
||||||
|
queryFn: stockService.getHeatmapData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync initial data into state
|
||||||
|
if (initialHeatmap && heatmapData.length === 0) {
|
||||||
|
setHeatmapData(initialHeatmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real-time WebSocket updates
|
||||||
|
const wsProto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const wsHost = window.location.host;
|
||||||
|
|
||||||
|
const handleWsMessage = useCallback((msg: unknown) => {
|
||||||
|
const m = msg as { type: string; data: StockQuote[] };
|
||||||
|
if (m.type === "heatmap" && Array.isArray(m.data)) {
|
||||||
|
setHeatmapData(m.data);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useWebSocket(`${wsProto}//${wsHost}/ws/heatmap`, handleWsMessage);
|
||||||
|
|
||||||
|
const displayData: StockQuote[] = heatmapData.length > 0 ? heatmapData : (initialHeatmap ?? []);
|
||||||
|
|
||||||
|
const upCount = displayData.filter((s) => s.change_pct > 0).length;
|
||||||
|
const downCount = displayData.filter((s) => s.change_pct < 0).length;
|
||||||
|
const flatCount = displayData.filter((s) => s.change_pct === 0).length;
|
||||||
|
|
||||||
|
const COLOR_LEGEND = [
|
||||||
|
{ label: "+9%", color: "#8b0000" },
|
||||||
|
{ label: "+6%", color: "#c0392b" },
|
||||||
|
{ label: "+3%", color: "#e74c3c" },
|
||||||
|
{ label: "+1%", color: "#e57373" },
|
||||||
|
{ label: "0%", color: "#4a4a4a" },
|
||||||
|
{ label: "-1%", color: "#80cbc4" },
|
||||||
|
{ label: "-3%", color: "#26a69a" },
|
||||||
|
{ label: "-6%", color: "#00897b" },
|
||||||
|
{ label: "-9%", color: "#004d40" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12, height: "calc(100vh - 84px)" }}>
|
||||||
|
{/* Index bar */}
|
||||||
|
<Row gutter={12} wrap={false}>
|
||||||
|
{(overview ?? []).map((idx) => (
|
||||||
|
<Col key={idx.index_code} flex="1 1 0">
|
||||||
|
<IndexCard idx={idx} />
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
<Col style={{ display: "flex", alignItems: "center", gap: 8, flexShrink: 0 }}>
|
||||||
|
<Tag color="red" style={{ marginRight: 0 }}>涨 {upCount}</Tag>
|
||||||
|
<Tag color="default" style={{ marginRight: 0 }}>平 {flatCount}</Tag>
|
||||||
|
<Tag color="green" style={{ marginRight: 0 }}>跌 {downCount}</Tag>
|
||||||
|
{heatmapLoading && <SyncOutlined spin style={{ color: "#1677ff", fontSize: 14 }} />}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Heatmap */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: "#161b22",
|
||||||
|
border: "1px solid #30363d",
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderBottom: "1px solid #30363d",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "#e6edf3", fontWeight: 600, fontSize: 14 }}>大盘云图</span>
|
||||||
|
<span style={{ color: "#8b949e", fontSize: 12 }}>A股实时涨跌幅 · 按成交额加权</span>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<Space size={6}>
|
||||||
|
{COLOR_LEGEND.map(({ label, color }) => (
|
||||||
|
<span key={label} style={{ fontSize: 10, color: "#8b949e", display: "flex", alignItems: "center", gap: 2 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
background: color,
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
<HeatMap data={displayData} loading={heatmapLoading && displayData.length === 0} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
frontend/src/pages/Login/LoginPage.tsx
Normal file
188
frontend/src/pages/Login/LoginPage.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
|
import { Form, Input, Button, Card, Tabs, message, Typography } from "antd";
|
||||||
|
import { UserOutlined, LockOutlined, MailOutlined } from "@ant-design/icons";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import { authService } from "@/services/auth";
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { login, loading } = useAuthStore();
|
||||||
|
const [activeTab, setActiveTab] = useState("login");
|
||||||
|
const [registerLoading, setRegisterLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleLogin = async (values: { username: string; password: string }) => {
|
||||||
|
try {
|
||||||
|
await login(values.username, values.password);
|
||||||
|
message.success("登录成功");
|
||||||
|
navigate("/");
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.detail || "登录失败,请检查用户名和密码");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegister = async (values: {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
confirm: string;
|
||||||
|
}) => {
|
||||||
|
if (values.password !== values.confirm) {
|
||||||
|
return message.error("两次密码不一致");
|
||||||
|
}
|
||||||
|
setRegisterLoading(true);
|
||||||
|
try {
|
||||||
|
await authService.register(values.username, values.email, values.password);
|
||||||
|
message.success("注册成功,请登录");
|
||||||
|
setActiveTab("login");
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.detail || "注册失败");
|
||||||
|
} finally {
|
||||||
|
setRegisterLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
background: "#0d1117",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: 400 }}>
|
||||||
|
<div style={{ textAlign: "center", marginBottom: 32 }}>
|
||||||
|
<Title level={2} style={{ color: "#58a6ff", marginBottom: 4 }}>
|
||||||
|
股票行情平台
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary">实时行情 · K线分析 · 自选预警</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
background: "#161b22",
|
||||||
|
border: "1px solid #30363d",
|
||||||
|
borderRadius: 12,
|
||||||
|
}}
|
||||||
|
bodyStyle={{ padding: 32 }}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
centered
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: "login",
|
||||||
|
label: "登录",
|
||||||
|
children: (
|
||||||
|
<Form layout="vertical" onFinish={handleLogin} autoComplete="off">
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
rules={[{ required: true, message: "请输入用户名" }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<UserOutlined />}
|
||||||
|
placeholder="用户名"
|
||||||
|
size="large"
|
||||||
|
style={{ background: "#0d1117", border: "1px solid #30363d" }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: "请输入密码" }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
placeholder="密码"
|
||||||
|
size="large"
|
||||||
|
style={{ background: "#0d1117", border: "1px solid #30363d" }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item style={{ marginBottom: 0 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "register",
|
||||||
|
label: "注册",
|
||||||
|
children: (
|
||||||
|
<Form layout="vertical" onFinish={handleRegister} autoComplete="off">
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
rules={[{ required: true, message: "请输入用户名" }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<UserOutlined />}
|
||||||
|
placeholder="用户名(3-20位字母数字)"
|
||||||
|
size="large"
|
||||||
|
style={{ background: "#0d1117", border: "1px solid #30363d" }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="email"
|
||||||
|
rules={[{ required: true, type: "email", message: "请输入有效邮箱" }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<MailOutlined />}
|
||||||
|
placeholder="邮箱"
|
||||||
|
size="large"
|
||||||
|
style={{ background: "#0d1117", border: "1px solid #30363d" }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, min: 6, message: "密码至少6位" }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
placeholder="密码(至少6位)"
|
||||||
|
size="large"
|
||||||
|
style={{ background: "#0d1117", border: "1px solid #30363d" }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="confirm"
|
||||||
|
rules={[{ required: true, message: "请确认密码" }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
placeholder="确认密码"
|
||||||
|
size="large"
|
||||||
|
style={{ background: "#0d1117", border: "1px solid #30363d" }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item style={{ marginBottom: 0 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
loading={registerLoading}
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
247
frontend/src/pages/StockDetail/StockDetailPage.tsx
Normal file
247
frontend/src/pages/StockDetail/StockDetailPage.tsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Row, Col, Card, Button, Tag, Spin, Tooltip, message, Statistic, Space, Segmented,
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
StarOutlined, StarFilled, BellOutlined, ArrowLeftOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { stockService } from "@/services/stocks";
|
||||||
|
import { useWebSocket } from "@/hooks/useWebSocket";
|
||||||
|
import KLineChart from "@/components/Charts/KLineChart";
|
||||||
|
import IntraDayChart from "@/components/Charts/IntraDayChart";
|
||||||
|
import type { StockQuote, ChartType } from "@/types";
|
||||||
|
|
||||||
|
const CHART_OPTIONS: { label: string; value: ChartType }[] = [
|
||||||
|
{ label: "分时", value: "intraday" },
|
||||||
|
{ label: "五日", value: "fiveday" },
|
||||||
|
{ label: "日K", value: "daily" },
|
||||||
|
{ label: "周K", value: "weekly" },
|
||||||
|
{ label: "月K", value: "monthly" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function StockDetailPage() {
|
||||||
|
const { symbol = "" } = useParams<{ symbol: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [chartType, setChartType] = useState<ChartType>("daily");
|
||||||
|
const [liveQuote, setLiveQuote] = useState<StockQuote | null>(null);
|
||||||
|
|
||||||
|
// Quote
|
||||||
|
const { data: quote, isLoading: quoteLoading } = useQuery({
|
||||||
|
queryKey: ["quote", symbol],
|
||||||
|
queryFn: () => stockService.getQuote(symbol),
|
||||||
|
enabled: !!symbol,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayQuote = liveQuote || quote;
|
||||||
|
|
||||||
|
// Chart data
|
||||||
|
const { data: intraDayData = [], isLoading: intraDayLoading } = useQuery({
|
||||||
|
queryKey: ["intraday", symbol],
|
||||||
|
queryFn: () => stockService.getIntraday(symbol),
|
||||||
|
enabled: !!symbol && chartType === "intraday",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: fiveDayData = [], isLoading: fiveDayLoading } = useQuery({
|
||||||
|
queryKey: ["fiveday", symbol],
|
||||||
|
queryFn: () => stockService.getFiveDay(symbol),
|
||||||
|
enabled: !!symbol && chartType === "fiveday",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: klineData = [], isLoading: klineLoading } = useQuery({
|
||||||
|
queryKey: ["kline", symbol, chartType],
|
||||||
|
queryFn: () =>
|
||||||
|
stockService.getKLine(symbol, chartType === "daily" ? "daily" : chartType === "weekly" ? "weekly" : "monthly"),
|
||||||
|
enabled: !!symbol && ["daily", "weekly", "monthly"].includes(chartType),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watchlist status
|
||||||
|
const { data: watchlist = [] } = useQuery({
|
||||||
|
queryKey: ["watchlist"],
|
||||||
|
queryFn: stockService.getWatchlist,
|
||||||
|
});
|
||||||
|
const isWatched = watchlist.some((w) => w.symbol === symbol);
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: () => stockService.addToWatchlist(symbol, displayQuote?.name || symbol),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["watchlist"] });
|
||||||
|
message.success("已加入自选股");
|
||||||
|
},
|
||||||
|
onError: () => message.error("添加失败"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeMutation = useMutation({
|
||||||
|
mutationFn: () => stockService.removeFromWatchlist(symbol),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["watchlist"] });
|
||||||
|
message.success("已移除自选股");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// WebSocket real-time quote
|
||||||
|
const wsOrigin = window.location.protocol === "https:" ? "wss://" : "ws://";
|
||||||
|
const wsHost = window.location.host;
|
||||||
|
|
||||||
|
const handleWsMessage = useCallback((msg: unknown) => {
|
||||||
|
const m = msg as { type: string; symbol: string; data: StockQuote };
|
||||||
|
if (m.type === "quote" && m.symbol === symbol) {
|
||||||
|
setLiveQuote(m.data);
|
||||||
|
}
|
||||||
|
}, [symbol]);
|
||||||
|
|
||||||
|
useWebSocket(`${wsOrigin}${wsHost}/ws/quote/${symbol}`, handleWsMessage);
|
||||||
|
|
||||||
|
const isUp = (displayQuote?.change_pct || 0) > 0;
|
||||||
|
const isDown = (displayQuote?.change_pct || 0) < 0;
|
||||||
|
const priceColor = isUp ? "#f03e3e" : isDown ? "#00b368" : "#8b949e";
|
||||||
|
|
||||||
|
const chartLoading =
|
||||||
|
chartType === "intraday" ? intraDayLoading
|
||||||
|
: chartType === "fiveday" ? fiveDayLoading
|
||||||
|
: klineLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ background: "#161b22", border: "1px solid #30363d" }}
|
||||||
|
bodyStyle={{ padding: "12px 16px" }}
|
||||||
|
>
|
||||||
|
<Row align="middle" gutter={16} wrap={false}>
|
||||||
|
<Col>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
style={{ color: "#8b949e" }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
{quoteLoading ? (
|
||||||
|
<Spin size="small" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 18, fontWeight: 700, color: "#e6edf3" }}>
|
||||||
|
{displayQuote?.name || symbol}
|
||||||
|
</span>
|
||||||
|
<Tag color="default" style={{ fontSize: 12 }}>
|
||||||
|
{symbol}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col>
|
||||||
|
<span style={{ fontSize: 28, fontWeight: 700, color: priceColor }}>
|
||||||
|
{displayQuote?.price?.toFixed(2) ?? "--"}
|
||||||
|
</span>
|
||||||
|
<span style={{ marginLeft: 10, color: priceColor, fontSize: 14 }}>
|
||||||
|
{(displayQuote?.change_pct || 0) >= 0 ? "+" : ""}
|
||||||
|
{displayQuote?.change_pct?.toFixed(2) ?? "--"}%
|
||||||
|
</span>
|
||||||
|
<span style={{ marginLeft: 6, color: priceColor, fontSize: 14 }}>
|
||||||
|
{(displayQuote?.change || 0) >= 0 ? "+" : ""}
|
||||||
|
{displayQuote?.change?.toFixed(2) ?? "--"}
|
||||||
|
</span>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col flex="auto" />
|
||||||
|
|
||||||
|
<Col>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={isWatched ? <StarFilled style={{ color: "#ffd700" }} /> : <StarOutlined />}
|
||||||
|
onClick={() => isWatched ? removeMutation.mutate() : addMutation.mutate()}
|
||||||
|
style={{
|
||||||
|
background: "#21262d",
|
||||||
|
border: "1px solid #30363d",
|
||||||
|
color: isWatched ? "#ffd700" : "#8b949e",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isWatched ? "已自选" : "加自选"}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Key stats */}
|
||||||
|
<Row gutter={16} style={{ marginTop: 12 }}>
|
||||||
|
{[
|
||||||
|
{ label: "今开", value: displayQuote?.open?.toFixed(2) ?? "--" },
|
||||||
|
{ label: "最高", value: displayQuote?.high?.toFixed(2) ?? "--" },
|
||||||
|
{ label: "最低", value: displayQuote?.low?.toFixed(2) ?? "--" },
|
||||||
|
{ label: "昨收", value: displayQuote?.prev_close?.toFixed(2) ?? "--" },
|
||||||
|
{
|
||||||
|
label: "成交量",
|
||||||
|
value: displayQuote?.volume
|
||||||
|
? (displayQuote.volume / 10000).toFixed(0) + "万手"
|
||||||
|
: "--",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "成交额",
|
||||||
|
value: displayQuote?.amount
|
||||||
|
? (displayQuote.amount / 100000000).toFixed(2) + "亿"
|
||||||
|
: "--",
|
||||||
|
},
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<Col key={label}>
|
||||||
|
<span style={{ color: "#8b949e", fontSize: 12 }}>{label} </span>
|
||||||
|
<span style={{ color: "#e6edf3", fontSize: 12, fontWeight: 600 }}>{value}</span>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
<Card
|
||||||
|
style={{ background: "#161b22", border: "1px solid #30363d" }}
|
||||||
|
bodyStyle={{ padding: 0 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px 16px",
|
||||||
|
borderBottom: "1px solid #30363d",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Segmented
|
||||||
|
value={chartType}
|
||||||
|
onChange={(v) => setChartType(v as ChartType)}
|
||||||
|
options={CHART_OPTIONS}
|
||||||
|
style={{ background: "#21262d" }}
|
||||||
|
/>
|
||||||
|
{chartLoading && <Spin size="small" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: "8px 8px 0" }}>
|
||||||
|
{chartType === "intraday" && (
|
||||||
|
<IntraDayChart
|
||||||
|
data={intraDayData}
|
||||||
|
prevClose={displayQuote?.prev_close}
|
||||||
|
height={420}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{chartType === "fiveday" && (
|
||||||
|
<IntraDayChart
|
||||||
|
data={fiveDayData}
|
||||||
|
prevClose={displayQuote?.prev_close}
|
||||||
|
height={420}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{["daily", "weekly", "monthly"].includes(chartType) && (
|
||||||
|
<KLineChart data={klineData} height={420} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
frontend/src/pages/Watchlist/WatchlistPage.tsx
Normal file
168
frontend/src/pages/Watchlist/WatchlistPage.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Table, Button, Tag, Card, Typography, Popconfirm, message, Empty } from "antd";
|
||||||
|
import { DeleteOutlined, LineChartOutlined } from "@ant-design/icons";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { stockService } from "@/services/stocks";
|
||||||
|
import type { WatchlistItem, StockQuote } from "@/types";
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
export default function WatchlistPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { data: watchlist = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["watchlist"],
|
||||||
|
queryFn: stockService.getWatchlist,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch fetch quotes for watchlist symbols
|
||||||
|
const { data: heatmap = [] } = useQuery({
|
||||||
|
queryKey: ["market-heatmap"],
|
||||||
|
queryFn: stockService.getHeatmapData,
|
||||||
|
enabled: watchlist.length > 0,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const quoteMap: Record<string, StockQuote> = {};
|
||||||
|
for (const q of heatmap) quoteMap[q.symbol] = q;
|
||||||
|
|
||||||
|
const removeMutation = useMutation({
|
||||||
|
mutationFn: (symbol: string) => stockService.removeFromWatchlist(symbol),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["watchlist"] });
|
||||||
|
message.success("已移除");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: "股票",
|
||||||
|
dataIndex: "name",
|
||||||
|
key: "name",
|
||||||
|
render: (_: string, record: WatchlistItem) => (
|
||||||
|
<span>
|
||||||
|
<span style={{ color: "#e6edf3", fontWeight: 600 }}>{record.name}</span>
|
||||||
|
<Tag style={{ marginLeft: 8, fontSize: 11 }} color="default">
|
||||||
|
{record.symbol}
|
||||||
|
</Tag>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "最新价",
|
||||||
|
key: "price",
|
||||||
|
render: (_: unknown, record: WatchlistItem) => {
|
||||||
|
const q = quoteMap[record.symbol];
|
||||||
|
return q ? (
|
||||||
|
<span style={{ fontWeight: 600, color: "#e6edf3" }}>{q.price.toFixed(2)}</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "#8b949e" }}>--</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "涨跌幅",
|
||||||
|
key: "change_pct",
|
||||||
|
sorter: (a: WatchlistItem, b: WatchlistItem) =>
|
||||||
|
(quoteMap[a.symbol]?.change_pct || 0) - (quoteMap[b.symbol]?.change_pct || 0),
|
||||||
|
render: (_: unknown, record: WatchlistItem) => {
|
||||||
|
const q = quoteMap[record.symbol];
|
||||||
|
if (!q) return "--";
|
||||||
|
const color = q.change_pct > 0 ? "#f03e3e" : q.change_pct < 0 ? "#00b368" : "#8b949e";
|
||||||
|
return (
|
||||||
|
<span style={{ color, fontWeight: 600 }}>
|
||||||
|
{q.change_pct >= 0 ? "+" : ""}
|
||||||
|
{q.change_pct.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "涨跌额",
|
||||||
|
key: "change",
|
||||||
|
render: (_: unknown, record: WatchlistItem) => {
|
||||||
|
const q = quoteMap[record.symbol];
|
||||||
|
if (!q) return "--";
|
||||||
|
const color = q.change > 0 ? "#f03e3e" : q.change < 0 ? "#00b368" : "#8b949e";
|
||||||
|
return (
|
||||||
|
<span style={{ color }}>
|
||||||
|
{q.change >= 0 ? "+" : ""}
|
||||||
|
{q.change.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "成交额",
|
||||||
|
key: "amount",
|
||||||
|
render: (_: unknown, record: WatchlistItem) => {
|
||||||
|
const q = quoteMap[record.symbol];
|
||||||
|
if (!q?.amount) return "--";
|
||||||
|
return <span style={{ color: "#8b949e" }}>{(q.amount / 1e8).toFixed(2)}亿</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "action",
|
||||||
|
render: (_: unknown, record: WatchlistItem) => (
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
icon={<LineChartOutlined />}
|
||||||
|
onClick={() => navigate(`/stock/${record.symbol}`)}
|
||||||
|
style={{ color: "#58a6ff" }}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认移除该自选股?"
|
||||||
|
onConfirm={() => removeMutation.mutate(record.symbol)}
|
||||||
|
okText="移除"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
danger
|
||||||
|
loading={removeMutation.isPending}
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Title level={4} style={{ margin: 0, color: "#e6edf3" }}>
|
||||||
|
自选股
|
||||||
|
</Title>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
style={{ background: "#161b22", border: "1px solid #30363d" }}
|
||||||
|
bodyStyle={{ padding: 0 }}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
rowKey="symbol"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={watchlist}
|
||||||
|
loading={isLoading}
|
||||||
|
pagination={false}
|
||||||
|
locale={{ emptyText: <Empty description="暂无自选股,去大盘云图选股吧" /> }}
|
||||||
|
onRow={(record) => ({
|
||||||
|
style: { cursor: "pointer" },
|
||||||
|
onDoubleClick: () => navigate(`/stock/${record.symbol}`),
|
||||||
|
})}
|
||||||
|
style={{ background: "transparent" }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
frontend/src/services/api.ts
Normal file
47
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import axios, { AxiosInstance } from "axios";
|
||||||
|
|
||||||
|
const BASE_URL = (import.meta as any).env?.VITE_API_BASE_URL || "/api/v1";
|
||||||
|
|
||||||
|
const api: AxiosInstance = axios.create({
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
timeout: 15000,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach access token to every request
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-refresh on 401
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
async (error) => {
|
||||||
|
const original = error.config;
|
||||||
|
if (error.response?.status === 401 && !original._retry) {
|
||||||
|
original._retry = true;
|
||||||
|
const refreshToken = localStorage.getItem("refresh_token");
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post(`${BASE_URL}/auth/refresh`, {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
});
|
||||||
|
localStorage.setItem("access_token", data.access_token);
|
||||||
|
localStorage.setItem("refresh_token", data.refresh_token);
|
||||||
|
original.headers.Authorization = `Bearer ${data.access_token}`;
|
||||||
|
return api(original);
|
||||||
|
} catch {
|
||||||
|
localStorage.clear();
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
24
frontend/src/services/auth.ts
Normal file
24
frontend/src/services/auth.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import api from "./api";
|
||||||
|
import type { User, TokenResponse } from "@/types";
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
async login(username: string, password: string): Promise<TokenResponse> {
|
||||||
|
const { data } = await api.post<TokenResponse>("/auth/login", { username, password });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async register(username: string, email: string, password: string): Promise<User> {
|
||||||
|
const { data } = await api.post<User>("/auth/register", { username, email, password });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMe(): Promise<User> {
|
||||||
|
const { data } = await api.get<User>("/auth/me");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh(refresh_token: string): Promise<TokenResponse> {
|
||||||
|
const { data } = await api.post<TokenResponse>("/auth/refresh", { refresh_token });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
88
frontend/src/services/stocks.ts
Normal file
88
frontend/src/services/stocks.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import api from "./api";
|
||||||
|
import type { StockQuote, KLineBar, IntraDayBar, MarketIndex, WatchlistItem, Alert } from "@/types";
|
||||||
|
|
||||||
|
export const stockService = {
|
||||||
|
// Market
|
||||||
|
async getMarketOverview(): Promise<MarketIndex[]> {
|
||||||
|
const { data } = await api.get("/stocks/market/overview");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getHeatmapData(): Promise<StockQuote[]> {
|
||||||
|
const { data } = await api.get("/stocks/market/heatmap");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSectors(): Promise<{ sector: string; change_pct: number }[]> {
|
||||||
|
const { data } = await api.get("/stocks/market/sectors");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stock detail
|
||||||
|
async getQuote(symbol: string): Promise<StockQuote> {
|
||||||
|
const { data } = await api.get(`/stocks/${symbol}/quote`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getKLine(symbol: string, period = "daily", adjust = "qfq", limit = 250): Promise<KLineBar[]> {
|
||||||
|
const { data } = await api.get(`/stocks/${symbol}/kline`, {
|
||||||
|
params: { period, adjust, limit },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getIntraday(symbol: string): Promise<IntraDayBar[]> {
|
||||||
|
const { data } = await api.get(`/stocks/${symbol}/intraday`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getFiveDay(symbol: string): Promise<IntraDayBar[]> {
|
||||||
|
const { data } = await api.get(`/stocks/${symbol}/fiveday`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async searchStocks(q: string): Promise<{ symbol: string; name: string; market: string }[]> {
|
||||||
|
const { data } = await api.get("/stocks/search", { params: { q } });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Watchlist
|
||||||
|
async getWatchlist(): Promise<WatchlistItem[]> {
|
||||||
|
const { data } = await api.get("/watchlist");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async addToWatchlist(symbol: string, name: string): Promise<WatchlistItem> {
|
||||||
|
const { data } = await api.post("/watchlist", { symbol, name });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeFromWatchlist(symbol: string): Promise<void> {
|
||||||
|
await api.delete(`/watchlist/${symbol}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Alerts
|
||||||
|
async getAlerts(): Promise<Alert[]> {
|
||||||
|
const { data } = await api.get("/alerts");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createAlert(payload: {
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
alert_type: string;
|
||||||
|
threshold: number;
|
||||||
|
}): Promise<Alert> {
|
||||||
|
const { data } = await api.post("/alerts", payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteAlert(id: number): Promise<void> {
|
||||||
|
await api.delete(`/alerts/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleAlert(id: number): Promise<Alert> {
|
||||||
|
const { data } = await api.patch(`/alerts/${id}/toggle`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
49
frontend/src/stores/authStore.ts
Normal file
49
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { User } from "@/types";
|
||||||
|
import { authService } from "@/services/auth";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
fetchMe: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: !!localStorage.getItem("access_token"),
|
||||||
|
loading: false,
|
||||||
|
|
||||||
|
login: async (username, password) => {
|
||||||
|
set({ loading: true });
|
||||||
|
try {
|
||||||
|
const tokens = await authService.login(username, password);
|
||||||
|
localStorage.setItem("access_token", tokens.access_token);
|
||||||
|
localStorage.setItem("refresh_token", tokens.refresh_token);
|
||||||
|
const user = await authService.getMe();
|
||||||
|
set({ user, isAuthenticated: true });
|
||||||
|
} finally {
|
||||||
|
set({ loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
set({ user: null, isAuthenticated: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchMe: async () => {
|
||||||
|
if (!localStorage.getItem("access_token")) return;
|
||||||
|
try {
|
||||||
|
const user = await authService.getMe();
|
||||||
|
set({ user, isAuthenticated: true });
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
set({ user: null, isAuthenticated: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
74
frontend/src/types/index.ts
Normal file
74
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_admin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockQuote {
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
change: number;
|
||||||
|
change_pct: number;
|
||||||
|
open: number;
|
||||||
|
high: number;
|
||||||
|
low: number;
|
||||||
|
prev_close: number;
|
||||||
|
volume: number;
|
||||||
|
amount: number;
|
||||||
|
sector?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KLineBar {
|
||||||
|
date: string;
|
||||||
|
open: number;
|
||||||
|
high: number;
|
||||||
|
low: number;
|
||||||
|
close: number;
|
||||||
|
volume: number;
|
||||||
|
amount?: number;
|
||||||
|
change_pct?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntraDayBar {
|
||||||
|
time: string;
|
||||||
|
price: number;
|
||||||
|
volume: number;
|
||||||
|
amount?: number;
|
||||||
|
avg_price?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketIndex {
|
||||||
|
index_code: string;
|
||||||
|
index_name: string;
|
||||||
|
current: number;
|
||||||
|
change: number;
|
||||||
|
change_pct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchlistItem {
|
||||||
|
id: number;
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Alert {
|
||||||
|
id: number;
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
alert_type: string;
|
||||||
|
threshold: number;
|
||||||
|
is_active: boolean;
|
||||||
|
triggered: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChartType = "intraday" | "fiveday" | "daily" | "weekly" | "monthly";
|
||||||
24
frontend/tsconfig.json
Normal file
24
frontend/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
26
frontend/vite.config.ts
Normal file
26
frontend/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:8000",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
"/ws": {
|
||||||
|
target: "ws://localhost:8000",
|
||||||
|
ws: true,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
46
nginx/nginx.conf
Normal file
46
nginx/nginx.conf
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
worker_processes auto;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
upstream backend {
|
||||||
|
server backend:8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend:80;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket
|
||||||
|
location /ws/ {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
scripts/dev-windows.ps1
Normal file
56
scripts/dev-windows.ps1
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Windows 本地开发启动脚本
|
||||||
|
# 在项目根目录运行:.\scripts\dev-windows.ps1
|
||||||
|
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location $root
|
||||||
|
|
||||||
|
Write-Host "=== 股票行情平台 - Windows 本地开发 ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# 1. 启动 PostgreSQL + Redis
|
||||||
|
Write-Host "`n[1/4] 启动数据库服务 (Docker)..." -ForegroundColor Yellow
|
||||||
|
docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "错误:Docker 未安装或未启动,请先安装 Docker Desktop" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
|
||||||
|
# 2. 后端虚拟环境
|
||||||
|
Write-Host "`n[2/4] 启动后端 (FastAPI)..." -ForegroundColor Yellow
|
||||||
|
Set-Location "$root\backend"
|
||||||
|
|
||||||
|
if (-not (Test-Path "venv")) {
|
||||||
|
Write-Host " 创建虚拟环境..."
|
||||||
|
python -m venv venv
|
||||||
|
.\venv\Scripts\Activate.ps1
|
||||||
|
pip install -r requirements.txt
|
||||||
|
} else {
|
||||||
|
.\venv\Scripts\Activate.ps1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 后台运行 FastAPI
|
||||||
|
Start-Process powershell -ArgumentList "-NoExit", "-Command", `
|
||||||
|
"Set-Location '$root\backend'; .\venv\Scripts\Activate.ps1; uvicorn app.main:app --reload --port 8000" `
|
||||||
|
-WindowStyle Normal
|
||||||
|
|
||||||
|
# 3. Celery Worker(Windows 用 --pool=solo)
|
||||||
|
Write-Host "`n[3/4] 启动 Celery Worker..." -ForegroundColor Yellow
|
||||||
|
Start-Process powershell -ArgumentList "-NoExit", "-Command", `
|
||||||
|
"Set-Location '$root\backend'; .\venv\Scripts\Activate.ps1; celery -A celery_app.worker worker -l info --pool=solo" `
|
||||||
|
-WindowStyle Normal
|
||||||
|
|
||||||
|
# 4. 前端
|
||||||
|
Write-Host "`n[4/4] 启动前端 (Vite)..." -ForegroundColor Yellow
|
||||||
|
Set-Location "$root\frontend"
|
||||||
|
if (-not (Test-Path "node_modules")) {
|
||||||
|
npm install
|
||||||
|
}
|
||||||
|
Start-Process powershell -ArgumentList "-NoExit", "-Command", `
|
||||||
|
"Set-Location '$root\frontend'; npm run dev" `
|
||||||
|
-WindowStyle Normal
|
||||||
|
|
||||||
|
Write-Host "`n=== 启动完成 ===" -ForegroundColor Green
|
||||||
|
Write-Host "前端: http://localhost:5173" -ForegroundColor Cyan
|
||||||
|
Write-Host "后端 API: http://localhost:8000/api/docs" -ForegroundColor Cyan
|
||||||
|
Write-Host "PostgreSQL: localhost:5432" -ForegroundColor Cyan
|
||||||
|
Write-Host "Redis: localhost:6379" -ForegroundColor Cyan
|
||||||
Reference in New Issue
Block a user