1
This commit is contained in:
parent
bb402cab00
commit
45e4c36b50
Binary file not shown.
Binary file not shown.
@ -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}%)"
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -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]}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.
0
backend/scripts/__init__.py
Normal file
0
backend/scripts/__init__.py
Normal file
34
backend/scripts/cleanup_recommendations.py
Normal file
34
backend/scripts/cleanup_recommendations.py
Normal 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())
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
2
frontend/.next/cache/.tsbuildinfo
vendored
2
frontend/.next/cache/.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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\"]}}"
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "s33pKc3VjlvggFQFOCpPXrHp6MilpQP7rFdwHOWbtO8="
|
||||
"encryptionKey": "3EcBu3/DLHcDJDy/e3ozITIuj0jdVQNPr7Acwbdh1AI="
|
||||
}
|
||||
@ -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
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user