This commit is contained in:
aaron 2026-04-16 16:40:56 +08:00
parent bb402cab00
commit 45e4c36b50
23 changed files with 312 additions and 620 deletions

View File

@ -269,59 +269,49 @@ def _score_intraday(quote: StockQuote) -> float:
async def intraday_sector_scan(prev_sectors: list[SectorInfo]) -> list[SectorInfo]:
"""盘中板块热度更新:用腾讯实时行情刷新板块涨幅和涨停数
"""盘中板块热度更新:用东方财富实时板块数据刷新涨幅和涨停数
基于前一日的板块列表来自 Tushare用成分股的实时行情
重新计算板块涨跌幅和涨停家数
一次请求替代之前腾讯批量获取数千只成分股的方式
"""
if not prev_sectors:
return prev_sectors
# 收集所有板块的成分股
sector_members: dict[str, list[str]] = {}
all_codes = set()
for sector in prev_sectors:
members = tushare_client.get_ths_members(sector.sector_code)
if members.empty or "con_code" not in members.columns:
continue
codes = [c for c in members["con_code"].tolist() if "." in str(c)]
sector_members[sector.sector_code] = codes
all_codes.update(codes)
if not all_codes:
# 从东方财富获取实时板块排名1次 HTTP 请求)
try:
from app.data.eastmoney_client import get_sector_realtime_ranking
em_sectors = await get_sector_realtime_ranking()
except Exception as e:
logger.warning(f"东方财富板块实时数据获取失败: {e}")
return prev_sectors
# 批量获取实时行情
quotes = await tencent_client.get_realtime_quotes_batch(list(all_codes))
if not quotes:
if not em_sectors:
return prev_sectors
# 构建涨停集合
limit_up_codes = set()
for code, q in quotes.items():
if q.limit_up and q.price >= q.limit_up * 0.995:
limit_up_codes.add(code)
# 更新每个板块的数据
# 按板块名称匹配更新数据
matched = 0
for sector in prev_sectors:
codes = sector_members.get(sector.sector_code, [])
if not codes:
continue
em_data = None
# 先精确匹配
for em_s in em_sectors:
if em_s["sector_name"] == sector.sector_name:
em_data = em_s
break
# 模糊匹配
if not em_data:
for em_s in em_sectors:
em_name = em_s["sector_name"].rstrip("行业").rstrip("板块").strip()
ts_name = sector.sector_name.rstrip("行业").rstrip("板块").strip()
if em_name == ts_name or (len(em_name) <= len(ts_name) and em_name in ts_name) or (len(ts_name) <= len(em_name) and ts_name in em_name):
em_data = em_s
break
sector_quotes = [quotes[c] for c in codes if c in quotes]
if not sector_quotes:
continue
# 实时涨跌幅(成分股均值)
pct_changes = [q.pct_chg for q in sector_quotes if q.pct_chg is not None]
if pct_changes:
sector.pct_change = round(sum(pct_changes) / len(pct_changes), 2)
# 实时涨停家数
sector.limit_up_count = len([c for c in codes if c in limit_up_codes])
if em_data:
matched += 1
sector.pct_change = em_data["pct_change"]
# 涨停数保留 Tushare 数据(东方财富此字段不可用)
logger.info(
f"盘中板块实时更新: {len(prev_sectors)} 个板块, "
f"盘中板块实时更新: {matched}/{len(prev_sectors)} 匹配成功, "
f"涨幅最高={max(prev_sectors, key=lambda s: s.pct_change).sector_name} "
f"({max(s.pct_change for s in prev_sectors):.1f}%)"
)

View File

@ -1,171 +0,0 @@
"""涨跌停/异动监控 API
盘后使用 Tushare 涨跌停列表和日级数据完整准确
盘中涨跌停仍用 Tushare异动股用腾讯实时行情量比/振幅/急涨急跌
"""
from fastapi import APIRouter
from app.data.tushare_client import tushare_client
from app.config import is_market_session
router = APIRouter(prefix="/api/monitor", tags=["monitor"])
@router.get("/limits")
async def get_limits():
"""获取涨跌停数据"""
trade_date = tushare_client.get_latest_trade_date()
is_realtime = is_market_session()
limit_df = tushare_client.get_limit_list(trade_date)
if limit_df.empty:
return {"trade_date": trade_date, "is_realtime": False, "limit_up": [], "limit_down": []}
# 拆分涨停和跌停
up_df = limit_df[limit_df["limit"] == "U"].sort_values("pct_chg", ascending=False)
down_df = limit_df[limit_df["limit"] == "D"].sort_values("pct_chg", ascending=True)
def _parse(df):
results = []
for _, r in df.head(50).iterrows():
results.append({
"ts_code": r.get("ts_code", ""),
"name": r.get("name", ""),
"close": float(r.get("close", 0)),
"pct_chg": float(r.get("pct_chg", 0)),
"limit_times": int(r.get("limit_times", 0)),
"first_time": str(r.get("first_time", "")),
"last_time": str(r.get("last_time", "")),
"open_times": int(r.get("open_times", 0)),
"fd_amount": float(r.get("fd_amount", 0)) if r.get("fd_amount") else 0,
"up_stat": str(r.get("up_stat", "")),
})
return results
return {
"trade_date": trade_date,
"is_realtime": is_realtime,
"limit_up": _parse(up_df),
"limit_down": _parse(down_df),
}
@router.get("/unusual")
async def get_unusual():
"""获取异动股(量比>3、振幅>8%、快速拉升)
盘中时使用腾讯实时行情补充量比和涨跌幅
盘后使用 Tushare 日级数据
"""
trade_date = tushare_client.get_latest_trade_date()
is_realtime = is_market_session()
if is_realtime:
# 盘中:用腾讯实时行情扫描异动
return await _get_unusual_realtime(trade_date)
# 盘后:使用 Tushare 日级数据
daily = tushare_client.get_daily_all(trade_date)
if daily.empty:
return {"trade_date": trade_date, "is_realtime": False, "stocks": []}
basic = tushare_client.get_daily_basic(trade_date)
if not basic.empty:
daily = daily.merge(basic[["ts_code", "turnover_rate", "volume_ratio"]], on="ts_code", how="left")
stock_basic = tushare_client.get_stock_basic()
name_map = {}
if not stock_basic.empty:
for _, r in stock_basic.iterrows():
name_map[r["ts_code"]] = r["name"]
unusual = []
for _, r in daily.iterrows():
ts = r.get("ts_code", "")
if not ts.endswith((".SH", ".SZ")):
continue
pct = r.get("pct_chg", 0)
vol_ratio = r.get("volume_ratio", 0)
high = r.get("high", 0)
low = r.get("low", 0)
pre_close = r.get("pre_close", 0)
amplitude = (high - low) / pre_close * 100 if pre_close > 0 else 0
tags = []
if vol_ratio and vol_ratio > 3:
tags.append("巨量")
if amplitude > 8:
tags.append("高振幅")
if pct > 7:
tags.append("急涨")
elif pct < -7:
tags.append("急跌")
if tags:
unusual.append({
"ts_code": ts,
"name": name_map.get(ts, ts),
"close": float(r.get("close", 0)),
"pct_chg": float(pct),
"amplitude": round(float(amplitude), 2),
"volume_ratio": round(float(vol_ratio), 2) if vol_ratio and vol_ratio == vol_ratio else 0,
"turnover_rate": round(float(r.get("turnover_rate", 0)), 2),
"tags": tags,
})
unusual.sort(key=lambda x: abs(x["pct_chg"]), reverse=True)
return {"trade_date": trade_date, "is_realtime": False, "stocks": unusual[:50]}
async def _get_unusual_realtime(trade_date: str) -> dict:
"""盘中:用腾讯实时行情扫描异动"""
from app.data.tencent_client import get_realtime_quotes_batch
stock_basic = tushare_client.get_stock_basic()
if stock_basic.empty:
return {"trade_date": trade_date, "is_realtime": True, "stocks": []}
# 只扫描主板(非 ST
valid = stock_basic[
~stock_basic["name"].str.contains("ST", na=False)
]
codes = valid["ts_code"].tolist()
# 分批获取实时行情
unusual = []
batch_size = 200
for i in range(0, len(codes), batch_size):
batch = codes[i:i + batch_size]
quotes = await get_realtime_quotes_batch(batch)
for ts_code, q in quotes.items():
if not q.price or q.price <= 0:
continue
tags = []
if q.volume_ratio and q.volume_ratio > 3:
tags.append("巨量")
if q.amplitude and q.amplitude > 8:
tags.append("高振幅")
if q.pct_chg > 7:
tags.append("急涨")
elif q.pct_chg < -7:
tags.append("急跌")
if tags:
unusual.append({
"ts_code": ts_code,
"name": q.name or ts_code,
"close": q.price,
"pct_chg": q.pct_chg,
"amplitude": round(q.amplitude, 2) if q.amplitude else 0,
"volume_ratio": round(q.volume_ratio, 2) if q.volume_ratio else 0,
"turnover_rate": round(q.turnover_rate, 2) if q.turnover_rate else 0,
"tags": tags,
})
unusual.sort(key=lambda x: abs(x["pct_chg"]), reverse=True)
return {"trade_date": trade_date, "is_realtime": True, "stocks": unusual[:50]}

View File

@ -6,7 +6,7 @@ from fastapi import APIRouter
from app.config import is_market_session
from app.data.tushare_client import tushare_client
from app.data.tencent_client import get_realtime_quotes_batch
from app.data.eastmoney_client import get_sector_realtime_ranking
from app.data.cache import cache
from app.engine.recommender import get_latest_sectors
@ -14,8 +14,28 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/sectors", tags=["sectors"])
def _match_sector_name(em_name: str, ts_name: str) -> bool:
"""东方财富板块名与 Tushare 板块名模糊匹配
东方财富用"酿酒行业"Tushare 可能叫"白酒"
东方财富用"汽车整车"Tushare 可能叫"汽车"
用包含匹配短名在长名中或尾部去掉"行业"后完全匹配
"""
# 去掉常见后缀再做比较
em_clean = em_name.rstrip("行业").rstrip("板块").rstrip("概念").strip()
ts_clean = ts_name.rstrip("行业").rstrip("板块").rstrip("概念").strip()
if em_clean == ts_clean:
return True
# 短名包含在长名中
short, long = (em_clean, ts_clean) if len(em_clean) <= len(ts_clean) else (ts_clean, em_clean)
return short in long
async def _enrich_sectors_realtime(sectors_data: list[dict]) -> list[dict]:
"""盘中时,用腾讯实时行情补充板块涨幅和涨停数"""
"""盘中时,用东方财富实时板块数据补充涨幅和涨停数
一次请求替代之前腾讯批量获取数千只成分股的方式
"""
if not is_market_session():
for s in sectors_data:
s["realtime_pct_change"] = None
@ -23,71 +43,64 @@ async def _enrich_sectors_realtime(sectors_data: list[dict]) -> list[dict]:
s["is_realtime"] = False
return sectors_data
# 收集所有板块的成分股代码
sector_members: dict[str, list[str]] = {}
all_codes: list[str] = []
for s in sectors_data:
code = s["sector_code"]
try:
df = tushare_client.get_ths_members(code)
members = df["con_code"].tolist() if not df.empty else []
except Exception:
members = []
sector_members[code] = members
all_codes.extend(members)
if not all_codes:
for s in sectors_data:
s["realtime_pct_change"] = None
s["realtime_limit_up_count"] = None
s["is_realtime"] = True
return sectors_data
# 批量获取实时报价
# 从东方财富获取实时板块排名1次 HTTP 请求)
try:
quotes = await get_realtime_quotes_batch(all_codes)
em_sectors = await get_sector_realtime_ranking()
except Exception:
logger.warning("获取板块实时行情失败,回退到日级数据")
logger.warning("东方财富板块实时数据获取失败,回退到日级数据")
for s in sectors_data:
s["realtime_pct_change"] = None
s["realtime_limit_up_count"] = None
s["is_realtime"] = False
return sectors_data
# 为每个板块计算实时指标
if not em_sectors:
for s in sectors_data:
s["realtime_pct_change"] = None
s["realtime_limit_up_count"] = None
s["is_realtime"] = False
return sectors_data
# 构建东方财富板块名查找表(用于匹配)
em_name_map = {s["sector_name"]: s for s in em_sectors}
matched = 0
for s in sectors_data:
members = sector_members.get(s["sector_code"], [])
member_quotes = [quotes[c] for c in members if c in quotes]
ts_name = s["sector_name"]
# 尝试匹配:先精确,再模糊
em_data = em_name_map.get(ts_name)
if not em_data:
# 模糊匹配
for em_s in em_sectors:
if _match_sector_name(em_s["sector_name"], ts_name):
em_data = em_s
break
if member_quotes:
pct_changes = [q.pct_chg for q in member_quotes]
s["realtime_pct_change"] = round(sum(pct_changes) / len(pct_changes), 2)
s["realtime_limit_up_count"] = sum(
1 for q in member_quotes
if q.limit_up and q.price >= q.limit_up * 0.995
)
# 盘中更新领涨股
sorted_quotes = sorted(member_quotes, key=lambda q: q.pct_chg, reverse=True)
s["leading_stocks_realtime"] = [
{
"ts_code": q.ts_code,
"name": q.name or q.ts_code,
"pct_chg": round(q.pct_chg, 2),
"amount": round(q.amount, 0),
}
for q in sorted_quotes[:3]
]
if em_data:
matched += 1
s["realtime_pct_change"] = em_data["pct_change"]
s["is_realtime"] = True
# 涨停家数仍保留 Tushare 数据(东方财富此字段不可用)
s["realtime_limit_up_count"] = None
# 更新领涨股(东方财富直接提供)
if em_data.get("leading_stock_name"):
s["leading_stocks_realtime"] = [
{
"ts_code": em_data.get("leading_stock_code", ""),
"name": em_data.get("leading_stock_name", ""),
"pct_chg": em_data.get("leading_stock_pct", 0),
"amount": 0,
}
]
else:
s["realtime_pct_change"] = None
s["realtime_limit_up_count"] = None
s["leading_stocks_realtime"] = None
s["is_realtime"] = False
s["is_realtime"] = True
logger.info(f"板块实时数据: {matched}/{len(sectors_data)} 匹配成功")
# 盘中按实时涨幅重新排序
sectors_data.sort(key=lambda s: s.get("realtime_pct_change") or 0, reverse=True)
sectors_data.sort(key=lambda s: s.get("realtime_pct_change") or s.get("pct_change") or 0, reverse=True)
return sectors_data

View File

@ -1,7 +1,8 @@
"""东方财富分钟K线数据客户端
"""东方财富数据客户端
通过 push2his.eastmoney.com 获取 A 股分钟级 K 线数据
用于盘中量能分布分析和短线进场时机判断
通过 push2.eastmoney.com / push2his.eastmoney.com 获取实时数据
- 板块实时涨跌幅排名行业/概念板块
- A 股分钟级 K 线数据盘中量能分布分析
免费接口无需认证注意限频
"""
@ -16,12 +17,17 @@ from app.config import settings
logger = logging.getLogger(__name__)
# 东方财富分钟K线接口
# 东方财富接口
EASTMONEY_KLINE_URL = "http://push2his.eastmoney.com/api/qt/stock/kline/get"
SECTOR_LIST_URL = "http://push2.eastmoney.com/api/qt/clist/get"
HEADERS = {
"Referer": "http://finance.eastmoney.com",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
}
SECTOR_HEADERS = {
"Referer": "http://data.eastmoney.com",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
}
def _ts_code_to_eastmoney(ts_code: str) -> str:
@ -31,6 +37,102 @@ def _ts_code_to_eastmoney(ts_code: str) -> str:
return f"{prefix}.{code}"
async def get_sector_realtime_ranking(
fs: str = "m:90+t:2",
sort_by: str = "f3",
descending: bool = True,
page_size: int = 100,
) -> list[dict]:
"""获取东方财富板块实时涨跌幅排名
一次请求获取所有板块的实时数据盘中和盘后均可使用
替代通过腾讯批量获取成分股行情再手动计算的方式
Args:
fs: 板块类型
- "m:90+t:2": 行业板块东方财富分类约496个
- "m:90+t:3": 概念板块约493个
- "m:90+t:1": 地域板块约31个
sort_by: 排序字段默认 f3=涨跌幅
descending: True=降序涨幅从高到低
page_size: 返回板块数量
Returns:
list[dict]每个 dict 包含:
- sector_code: 东方财富板块代码 ( "BK0428")
- sector_name: 板块名称 ( "酿酒行业")
- pct_change: 实时涨跌幅 %
- amount: 成交额
- turnover_rate: 换手率 %
- up_count: 上涨家数
- down_count: 下跌家数
- leading_stock_name: 领涨股名称 (f128)
- leading_stock_code: 领涨股代码 (f140)
- leading_stock_pct: 领涨股涨跌幅 % (f141)
"""
cache_key = f"sector_rt:{fs}:{sort_by}"
cached = cache.get(cache_key)
if cached is not None:
return cached
params = {
"pn": "1",
"pz": str(page_size),
"po": "0" if descending else "1",
"np": "1",
"ut": "b1f8f8f8",
"fltt": "2",
"invt": "2",
"fid": sort_by,
"fs": fs,
"fields": "f2,f3,f4,f6,f8,f12,f14,f104,f105,f128,f140,f141",
}
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
SECTOR_LIST_URL,
params=params,
headers=SECTOR_HEADERS,
timeout=10,
)
data = resp.json()
items = data.get("data", {}).get("diff", [])
if not items:
logger.debug("东方财富板块实时排名无数据")
return []
result = []
for item in items:
# f3 可能是 "-"(停牌等异常情况)
pct = item.get("f3")
if pct == "-" or pct is None:
pct = 0.0
result.append({
"sector_code": item.get("f12", ""),
"sector_name": item.get("f14", ""),
"pct_change": float(pct),
"amount": float(item.get("f6", 0) or 0),
"turnover_rate": float(item.get("f8", 0) or 0),
"up_count": int(item.get("f104", 0) or 0),
"down_count": int(item.get("f105", 0) or 0),
"leading_stock_name": item.get("f128", ""),
"leading_stock_code": item.get("f140", ""),
"leading_stock_pct": float(item.get("f141", 0) or 0),
})
# 缓存:盘中 60 秒,盘后 300 秒
ttl = 60 if _is_trading_hours() else 300
cache.set(cache_key, result, ttl)
logger.info(f"东方财富板块实时排名: 获取 {len(result)} 个板块")
return result
except Exception as e:
logger.error(f"东方财富板块实时排名获取失败: {e}")
return []
async def get_min_kline(
ts_code: str,
period: str = "5",

View File

@ -8,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.db.database import init_db
from app.engine.scheduler import start_scheduler, stop_scheduler
from app.api import market, sectors, recommendations, stocks, websocket, chat, auth, monitor
from app.api import market, sectors, recommendations, stocks, websocket, chat, auth
logging.basicConfig(
level=logging.DEBUG if settings.debug else logging.INFO,
@ -80,7 +80,6 @@ app.include_router(recommendations.router)
app.include_router(stocks.router)
app.include_router(chat.router)
app.include_router(auth.router)
app.include_router(monitor.router)
# WebSocket
app.websocket("/ws")(websocket.ws_endpoint)

Binary file not shown.

View File

View File

@ -0,0 +1,34 @@
"""删除评分低于 60 的推荐记录
用法: python -m scripts.cleanup_recommendations
"""
import asyncio
from sqlalchemy import text
from app.db.database import get_db
async def cleanup():
async with get_db() as db:
# 先统计
result = await db.execute(
text("SELECT COUNT(*) FROM recommendations WHERE score < 60")
)
count = result.scalar()
print(f"找到 {count} 条评分低于 60 的推荐记录")
if count == 0:
print("无需清理")
return
# 删除
await db.execute(
text("DELETE FROM recommendations WHERE score < 60")
)
await db.commit()
print(f"已删除 {count} 条记录")
if __name__ == "__main__":
asyncio.run(cleanup())

View File

@ -10,6 +10,16 @@
"static/chunks/main-app.js",
"static/css/app/layout.css",
"static/chunks/app/layout.js"
],
"/sectors/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/sectors/page.js"
],
"/_not-found/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/_not-found/page.js"
]
}
}

File diff suppressed because one or more lines are too long

View File

@ -1 +1,8 @@
{}
{
"app/sectors/page.tsx -> echarts": {
"id": "app/sectors/page.tsx -> echarts",
"files": [
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
]
}
}

View File

@ -1,3 +1,5 @@
{
"/page": "app/page.js"
"/_not-found/page": "app/_not-found/page.js",
"/page": "app/page.js",
"/sectors/page": "app/sectors/page.js"
}

View File

@ -1 +1 @@
self.__REACT_LOADABLE_MANIFEST="{}"
self.__REACT_LOADABLE_MANIFEST="{\"app/sectors/page.tsx -> echarts\":{\"id\":\"app/sectors/page.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]}}"

View File

@ -1,5 +1,5 @@
{
"node": {},
"edge": {},
"encryptionKey": "s33pKc3VjlvggFQFOCpPXrHp6MilpQP7rFdwHOWbtO8="
"encryptionKey": "3EcBu3/DLHcDJDy/e3ozITIuj0jdVQNPr7Acwbdh1AI="
}

View File

@ -125,7 +125,7 @@
/******/
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("668607dc222a6966")
/******/ __webpack_require__.h = () => ("463c86bf832e0923")
/******/ })();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */

File diff suppressed because one or more lines are too long

View File

@ -1,274 +0,0 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { fetchAPI } from "@/lib/api";
import type { LimitsData, UnusualStock } from "@/lib/api";
import { ErrorBoundary } from "@/components/error-boundary";
export default function MonitorPage() {
const [tab, setTab] = useState<"limits" | "unusual">("limits");
const [limitsData, setLimitsData] = useState<LimitsData | null>(null);
const [unusualStocks, setUnusualStocks] = useState<UnusualStock[]>([]);
const [tradeDate, setTradeDate] = useState("");
const [loading, setLoading] = useState(true);
const loadData = useCallback(async () => {
try {
if (tab === "limits") {
const data = await fetchAPI<LimitsData>("/api/monitor/limits");
setLimitsData(data);
setTradeDate(data.trade_date);
} else {
const data = await fetchAPI<{ trade_date: string; stocks: UnusualStock[] }>("/api/monitor/unusual");
setUnusualStocks(data.stocks);
setTradeDate(data.trade_date);
}
} catch (e) {
console.error("加载监控数据失败:", e);
} finally {
setLoading(false);
}
}, [tab]);
useEffect(() => {
loadData();
}, [loadData]);
const isInTradingHours = useCallback(() => {
const now = new Date();
const hour = now.getHours();
const minute = now.getMinutes();
const day = now.getDay(); // 0=Sunday
if (day === 0 || day === 6) return false;
if ((hour === 9 && minute >= 30) || hour === 10 || (hour === 11 && minute <= 30)) return true;
if (hour === 13 || hour === 14 || (hour === 15 && minute === 0)) return true;
return false;
}, []);
useEffect(() => {
if (!isInTradingHours()) return;
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
}, [loadData, isInTradingHours]);
return (
<ErrorBoundary>
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
{/* Header */}
<div className="flex items-center justify-between mb-5 animate-fade-in-up">
<div>
<h1 className="text-lg font-bold tracking-tight"> / </h1>
<p className="text-xs text-text-muted mt-0.5">
{tradeDate && <span className="font-mono tabular-nums">{tradeDate}</span>}
{limitsData?.is_realtime && <span className="text-emerald-400/60 ml-1">· </span>}
</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 mb-5 animate-fade-in-up delay-75">
<button
onClick={() => { setTab("limits"); setLoading(true); }}
className={`text-xs px-4 py-2 rounded-xl font-medium transition-all ${
tab === "limits"
? "bg-gradient-to-r from-amber-500/25 to-amber-600/20 text-amber-400 border border-amber-500/15"
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
}`}
>
</button>
<button
onClick={() => { setTab("unusual"); setLoading(true); }}
className={`text-xs px-4 py-2 rounded-xl font-medium transition-all ${
tab === "unusual"
? "bg-gradient-to-r from-amber-500/25 to-amber-600/20 text-amber-400 border border-amber-500/15"
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
}`}
>
</button>
</div>
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-16 glass-card-static animate-shimmer" />
))}
</div>
) : tab === "limits" ? (
<LimitsView data={limitsData} />
) : (
<UnusualView stocks={unusualStocks} />
)}
</div>
</ErrorBoundary>
);
}
function LimitsView({ data }: { data: LimitsData | null }) {
if (!data) return <div className="glass-card-static p-8 text-center text-text-muted text-sm"></div>;
return (
<div className="space-y-5 animate-fade-in-up">
{/* Stats bar */}
<div className="grid grid-cols-2 gap-3">
<div className="glass-card-static p-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-red-500/10 flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2">
<path d="M12 2c.5 2.5-.5 5-2 7 1 0 2.5.5 3 2.5.5-2 2-3 3-4-1 3-1 6-4 8.5-1.5 1-3.5 1.5-5 1-1.5-.5-2.5-2-2.5-3.5 0-3 3-5 5-7.5C10 5 11 3.5 12 2z" />
</svg>
</div>
<div>
<div className="text-2xl font-bold font-mono tabular-nums text-red-400">{data.limit_up.length}</div>
<div className="text-[10px] text-text-muted"></div>
</div>
</div>
<div className="glass-card-static p-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2">
<path d="M12 22c-.5-2.5.5-5 2-7-1 0-2.5-.5-3-2.5-.5 2-2 3-3 4 1-3 1-6 4-8.5 1.5-1 3.5-1.5 5-1 1.5.5 2.5 2 2.5 3.5 0 3-3 5-5 7.5C14 19 13 20.5 12 22z" />
</svg>
</div>
<div>
<div className="text-2xl font-bold font-mono tabular-nums text-emerald-400">{data.limit_down.length}</div>
<div className="text-[10px] text-text-muted"></div>
</div>
</div>
</div>
{/* Limit Up List */}
{data.limit_up.length > 0 && (
<div>
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3"></h2>
<div className="space-y-2">
{data.limit_up.map((stock, i) => (
<LimitStockRow key={stock.ts_code} stock={stock} index={i} type="up" />
))}
</div>
</div>
)}
{/* Limit Down List */}
{data.limit_down.length > 0 && (
<div>
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3"></h2>
<div className="space-y-2">
{data.limit_down.map((stock, i) => (
<LimitStockRow key={stock.ts_code} stock={stock} index={i} type="down" />
))}
</div>
</div>
)}
</div>
);
}
function LimitStockRow({ stock, index, type }: { stock: LimitsData["limit_up"][0]; index: number; type: "up" | "down" }) {
const isUp = type === "up";
return (
<a
href={`/stock/${stock.ts_code}`}
className="flex items-center gap-3 px-4 py-3 glass-card hover:bg-surface-2 transition-colors animate-fade-in-up group"
style={{ animationDelay: `${index * 30}ms` }}
>
<span className={`w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-bold shrink-0 ${
isUp ? "bg-red-500/10 text-red-400" : "bg-emerald-500/10 text-emerald-400"
}`}>
{index + 1}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary truncate">{stock.name}</span>
<span className="text-[10px] text-text-muted font-mono tabular-nums">{stock.ts_code}</span>
</div>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-[10px] text-text-muted">
{stock.limit_times}
</span>
{stock.open_times > 0 && (
<span className="text-[10px] text-amber-400/80">
{stock.open_times}
</span>
)}
{stock.up_stat && (
<span className="text-[10px] text-text-muted/60">{stock.up_stat}</span>
)}
{stock.first_time && (
<span className="text-[10px] text-text-muted/60">
{stock.first_time}
</span>
)}
</div>
</div>
<div className="text-right shrink-0">
<div className={`text-sm font-bold font-mono tabular-nums ${isUp ? "text-red-400" : "text-emerald-400"}`}>
{stock.close.toFixed(2)}
</div>
<div className={`text-[10px] font-mono tabular-nums ${isUp ? "text-red-400/70" : "text-emerald-400/70"}`}>
{stock.pct_chg > 0 ? "+" : ""}{stock.pct_chg.toFixed(2)}%
</div>
</div>
</a>
);
}
function UnusualView({ stocks }: { stocks: UnusualStock[] }) {
if (!stocks.length) {
return <div className="glass-card-static p-8 text-center text-text-muted text-sm"></div>;
}
return (
<div className="space-y-2 animate-fade-in-up">
{stocks.map((stock, i) => (
<a
key={stock.ts_code}
href={`/stock/${stock.ts_code}`}
className="flex items-center gap-3 px-4 py-3 glass-card hover:bg-surface-2 transition-colors animate-fade-in-up"
style={{ animationDelay: `${i * 30}ms` }}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary truncate">{stock.name}</span>
<span className="text-[10px] text-text-muted font-mono tabular-nums">{stock.ts_code}</span>
</div>
<div className="flex items-center gap-2 mt-1">
{stock.tags.map((tag) => (
<span
key={tag}
className={`text-[10px] px-1.5 py-0.5 rounded-md font-medium ${
tag === "巨量" ? "bg-amber-500/10 text-amber-400 border border-amber-500/15" :
tag === "高振幅" ? "bg-purple-500/10 text-purple-400 border border-purple-500/15" :
tag === "急涨" ? "bg-red-500/10 text-red-400 border border-red-500/15" :
"bg-emerald-500/10 text-emerald-400 border border-emerald-500/15"
}`}
>
{tag}
</span>
))}
</div>
</div>
<div className="flex items-center gap-4 shrink-0">
<div className="text-right">
<div className="text-[10px] text-text-muted"></div>
<div className="text-xs font-mono tabular-nums text-text-secondary">{stock.amplitude.toFixed(1)}%</div>
</div>
<div className="text-right">
<div className="text-[10px] text-text-muted"></div>
<div className="text-xs font-mono tabular-nums text-text-secondary">
{stock.volume_ratio > 0 ? stock.volume_ratio.toFixed(1) : "-"}
</div>
</div>
<div className="text-right">
<div className={`text-sm font-bold font-mono tabular-nums ${
stock.pct_chg > 0 ? "text-red-400" : "text-emerald-400"
}`}>
{stock.pct_chg > 0 ? "+" : ""}{stock.pct_chg.toFixed(2)}%
</div>
<div className="text-[10px] text-text-muted font-mono tabular-nums">{stock.close.toFixed(2)}</div>
</div>
</div>
</a>
))}
</div>
);
}

View File

@ -34,15 +34,6 @@ function FireIcon() {
);
}
function MonitorIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
);
}
function DiagnoseIcon() {
return (
@ -98,10 +89,8 @@ export function SidebarNav() {
<nav className="flex-1 py-5 px-3 space-y-1">
<SideNavItem href="/" icon={<DashboardIcon />} label="总览" />
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐列表" />
<SideNavItem href="/monitor" icon={<MonitorIcon />} label="监控" />
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块分析" />
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="AI 诊断" />
<SideNavItem href="/chat" icon={<ChatIcon />} label="AI 对话" />
{user?.role === "admin" && (
<SideNavItem href="/users" icon={<UsersIcon />} label="用户管理" />
)}
@ -136,18 +125,12 @@ export function MobileBottomNav() {
<MobileNavItem href="/recommendations" label="推荐">
<TargetIcon />
</MobileNavItem>
<MobileNavItem href="/monitor" label="监控">
<MonitorIcon />
</MobileNavItem>
<MobileNavItem href="/sectors" label="板块">
<FireIcon />
</MobileNavItem>
<MobileNavItem href="/diagnose" label="诊断">
<DiagnoseIcon />
</MobileNavItem>
<MobileNavItem href="/chat" label="对话">
<ChatIcon />
</MobileNavItem>
</div>
</nav>
);

View File

@ -186,44 +186,6 @@ export interface TrackedRecommendation {
track_date: string;
}
// ---------- Monitor ----------
export interface LimitStock {
ts_code: string;
name: string;
close: number;
pct_chg: number;
limit_times: number;
first_time: string;
last_time: string;
open_times: number;
fd_amount: number;
up_stat: string;
}
export interface LimitsData {
trade_date: string;
is_realtime: boolean;
limit_up: LimitStock[];
limit_down: LimitStock[];
}
export interface UnusualStock {
ts_code: string;
name: string;
close: number;
pct_chg: number;
amplitude: number;
volume_ratio: number;
turnover_rate: number;
tags: string[];
}
export interface UnusualData {
trade_date: string;
stocks: UnusualStock[];
}
// ---------- Daily Review ----------
export interface DailyReview {