Initial commit: stock market platform

This commit is contained in:
admin
2026-06-11 01:41:47 +08:00
commit 63718906e9
62 changed files with 8962 additions and 0 deletions

16
.env.example Normal file
View 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
View 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
View 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+RedisDocker→ 启动 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
View 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
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

35
backend/app/api/deps.py Normal file
View 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

View 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)

View 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

View 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

View 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)

View 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,
)
)

View 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()

View 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
View 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

View 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
View 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"}

View 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"]

View 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")

View 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")

View 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")

View File

@@ -0,0 +1 @@

View 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}

View 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}

View File

@@ -0,0 +1 @@

View 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]

View File

@@ -0,0 +1 @@

View 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()

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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}")

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

37
frontend/package.json Normal file
View 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
View 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>
);
}

View 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
/>
);
}

View 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
/>
);
}

View 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 }} />;
}

View 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>
);
}

View 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
View 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
View 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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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;
},
};

View 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;
},
};

View 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 });
}
},
}));

View 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
View 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
View 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
View 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
View 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 WorkerWindows 用 --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