1
This commit is contained in:
parent
212fda0bbf
commit
55318c54e8
Binary file not shown.
@ -1,10 +1,13 @@
|
|||||||
"""板块分析 API"""
|
"""板块分析 API"""
|
||||||
|
|
||||||
|
import json
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.data.tushare_client import tushare_client
|
from app.data.tushare_client import tushare_client
|
||||||
from app.data.cache import cache
|
from app.data.cache import cache
|
||||||
from app.engine.recommender import get_latest_sectors
|
from app.engine.recommender import get_latest_sectors
|
||||||
|
from app.db.database import get_db
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/sectors", tags=["sectors"])
|
router = APIRouter(prefix="/api/sectors", tags=["sectors"])
|
||||||
|
|
||||||
@ -81,6 +84,173 @@ def _derive_status(sectors: list[dict]) -> str:
|
|||||||
return next(iter(statuses))
|
return next(iter(statuses))
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_json(raw: str | None):
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _member_theme_matches(value: str, target: str, aliases: list[str]) -> bool:
|
||||||
|
value_norm = (value or "").strip().lower()
|
||||||
|
target_norm = (target or "").strip().lower()
|
||||||
|
alias_norms = {(alias or "").strip().lower() for alias in aliases if alias}
|
||||||
|
if not value_norm or not target_norm:
|
||||||
|
return False
|
||||||
|
return value_norm == target_norm or value_norm in alias_norms or target_norm in value_norm or value_norm in target_norm
|
||||||
|
|
||||||
|
|
||||||
|
def _sector_identity_matches(value: str, target: str, aliases: list[str]) -> bool:
|
||||||
|
value_norm = (value or "").strip().lower()
|
||||||
|
target_norm = (target or "").strip().lower()
|
||||||
|
alias_norms = {(alias or "").strip().lower() for alias in aliases if alias}
|
||||||
|
if not value_norm or not target_norm:
|
||||||
|
return False
|
||||||
|
return value_norm == target_norm or target_norm in alias_norms or target_norm in value_norm or value_norm in target_norm
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sector_name}/detail")
|
||||||
|
async def get_sector_detail(sector_name: str):
|
||||||
|
"""获取板块详情与候选股投研观察。
|
||||||
|
|
||||||
|
只读取最近一次扫描沉淀的数据,不在页面访问时拉外部行情。
|
||||||
|
"""
|
||||||
|
sectors = await get_latest_sectors()
|
||||||
|
matched = next(
|
||||||
|
(
|
||||||
|
s for s in sectors
|
||||||
|
if _sector_identity_matches(s.sector_name, sector_name, [s.theme_name, *(s.theme_aliases or [])])
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not matched and sectors:
|
||||||
|
matched = next((s for s in sectors if _sector_identity_matches(sector_name, s.sector_name, [])), None)
|
||||||
|
|
||||||
|
aliases = [matched.theme_name, *(matched.theme_aliases or [])] if matched else []
|
||||||
|
target_name = matched.sector_name if matched else sector_name
|
||||||
|
|
||||||
|
async with get_db() as db:
|
||||||
|
latest_session = (
|
||||||
|
await db.execute(
|
||||||
|
text("SELECT scan_session FROM research_observations ORDER BY created_at DESC LIMIT 1")
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
if latest_session:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id, scan_session, scan_mode, ts_code, name, theme_name, stock_role, "
|
||||||
|
"action_plan, final_score, catalyst_score, theme_money_score, stock_money_score, "
|
||||||
|
"emotion_role_score, timing_score, entry_signal_type, elimination_reason, detail_json, created_at "
|
||||||
|
"FROM research_observations "
|
||||||
|
"WHERE scan_session = :session "
|
||||||
|
"ORDER BY final_score DESC LIMIT 300"
|
||||||
|
),
|
||||||
|
{"session": latest_session},
|
||||||
|
)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
members = []
|
||||||
|
for row in rows:
|
||||||
|
r = row._mapping
|
||||||
|
theme = r["theme_name"] or ""
|
||||||
|
if not _member_theme_matches(theme, target_name, aliases):
|
||||||
|
continue
|
||||||
|
detail = _safe_json(r["detail_json"])
|
||||||
|
stock_detail = detail.get("stock", {}) if isinstance(detail, dict) else {}
|
||||||
|
decision_detail = detail.get("decision", {}) if isinstance(detail, dict) else {}
|
||||||
|
members.append({
|
||||||
|
"ts_code": r["ts_code"],
|
||||||
|
"name": r["name"],
|
||||||
|
"theme_name": theme,
|
||||||
|
"stock_role": r["stock_role"] or "",
|
||||||
|
"action_plan": r["action_plan"] or "观察",
|
||||||
|
"final_score": r["final_score"] or 0,
|
||||||
|
"catalyst_score": r["catalyst_score"] or 0,
|
||||||
|
"theme_money_score": r["theme_money_score"] or 0,
|
||||||
|
"stock_money_score": r["stock_money_score"] or 0,
|
||||||
|
"emotion_role_score": r["emotion_role_score"] or 0,
|
||||||
|
"timing_score": r["timing_score"] or 0,
|
||||||
|
"entry_signal_type": r["entry_signal_type"] or "none",
|
||||||
|
"elimination_reason": r["elimination_reason"] or "",
|
||||||
|
"recall_tags": stock_detail.get("recall_tags", []),
|
||||||
|
"main_net_inflow": stock_detail.get("main_net_inflow", 0),
|
||||||
|
"inflow_ratio": stock_detail.get("inflow_ratio", 0),
|
||||||
|
"turnover_rate": stock_detail.get("turnover_rate", 0),
|
||||||
|
"volume_ratio": stock_detail.get("volume_ratio"),
|
||||||
|
"trigger_condition": decision_detail.get("trigger_condition", ""),
|
||||||
|
"invalidation_condition": decision_detail.get("invalidation_condition", ""),
|
||||||
|
"created_at": str(r["created_at"] or ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
leader_codes = {
|
||||||
|
str(item.get("ts_code") or "")
|
||||||
|
for item in ((matched.leading_stocks_realtime or matched.leading_stocks) if matched else [])
|
||||||
|
}
|
||||||
|
for item in members:
|
||||||
|
if item["ts_code"] in leader_codes and "龙头" not in item["stock_role"]:
|
||||||
|
item["stock_role"] = f"代表股/{item['stock_role'] or '候选'}"
|
||||||
|
|
||||||
|
action_counts = {"可操作": 0, "重点关注": 0, "观察": 0}
|
||||||
|
for item in members:
|
||||||
|
action_counts[item["action_plan"]] = action_counts.get(item["action_plan"], 0) + 1
|
||||||
|
|
||||||
|
sector_payload = None
|
||||||
|
if matched:
|
||||||
|
sector_payload = {
|
||||||
|
"sector_code": matched.sector_code,
|
||||||
|
"sector_name": matched.sector_name,
|
||||||
|
"board_type": matched.board_type,
|
||||||
|
"theme_id": matched.theme_id,
|
||||||
|
"theme_name": matched.theme_name,
|
||||||
|
"theme_aliases": matched.theme_aliases,
|
||||||
|
"pct_change": matched.pct_change,
|
||||||
|
"capital_inflow": matched.capital_inflow,
|
||||||
|
"limit_up_count": matched.limit_up_count,
|
||||||
|
"days_continuous": matched.days_continuous,
|
||||||
|
"heat_score": matched.heat_score,
|
||||||
|
"stage": matched.stage,
|
||||||
|
"member_count": matched.member_count,
|
||||||
|
"leading_stocks": matched.leading_stocks,
|
||||||
|
"leading_stocks_realtime": matched.leading_stocks_realtime,
|
||||||
|
"turnover_avg": matched.turnover_avg,
|
||||||
|
"main_force_ratio": matched.main_force_ratio,
|
||||||
|
"trade_date": matched.trade_date,
|
||||||
|
"realtime_pct_change": matched.realtime_pct_change,
|
||||||
|
"realtime_limit_up_count": matched.realtime_limit_up_count,
|
||||||
|
"realtime_amount": matched.realtime_amount,
|
||||||
|
"realtime_turnover_rate": matched.realtime_turnover_rate,
|
||||||
|
"realtime_up_count": matched.realtime_up_count,
|
||||||
|
"realtime_down_count": matched.realtime_down_count,
|
||||||
|
"is_realtime": matched.is_realtime,
|
||||||
|
"data_mode": matched.data_mode,
|
||||||
|
"source": matched.source,
|
||||||
|
"data_status": matched.data_status,
|
||||||
|
"source_detail": matched.source_detail,
|
||||||
|
"catalyst_score": matched.catalyst_score,
|
||||||
|
"catalyst_count": matched.catalyst_count,
|
||||||
|
"catalyst_reasons": matched.catalyst_reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sector": sector_payload,
|
||||||
|
"scan_session": latest_session or "",
|
||||||
|
"members": members,
|
||||||
|
"action_counts": action_counts,
|
||||||
|
"top_candidates": members[:12],
|
||||||
|
"summary": {
|
||||||
|
"member_count": len(members),
|
||||||
|
"actionable_count": action_counts.get("可操作", 0),
|
||||||
|
"watch_count": action_counts.get("重点关注", 0),
|
||||||
|
"observe_count": action_counts.get("观察", 0),
|
||||||
|
"avg_score": round(sum(item["final_score"] for item in members) / len(members), 1) if members else 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/rotation")
|
@router.get("/rotation")
|
||||||
async def get_sector_rotation(days: int = 5):
|
async def get_sector_rotation(days: int = 5):
|
||||||
"""获取近N日板块轮动数据(用于热力图)"""
|
"""获取近N日板块轮动数据(用于热力图)"""
|
||||||
|
|||||||
@ -1,20 +1,107 @@
|
|||||||
{
|
{
|
||||||
"pages": {
|
"pages": {
|
||||||
"/(auth)/recommendations/page": [
|
"/_not-found/page": [
|
||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||||
"static/chunks/main-app.js",
|
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
|
||||||
"static/chunks/app/(auth)/recommendations/page.js"
|
"static/chunks/117-a8e886455f28924b.js",
|
||||||
],
|
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||||
"/(auth)/layout": [
|
"static/chunks/app/_not-found/page-b80a7c484a1a45e7.js"
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main-app.js",
|
|
||||||
"static/chunks/app/(auth)/layout.js"
|
|
||||||
],
|
],
|
||||||
"/layout": [
|
"/layout": [
|
||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||||
"static/chunks/main-app.js",
|
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
|
||||||
"static/css/app/layout.css",
|
"static/chunks/117-a8e886455f28924b.js",
|
||||||
"static/chunks/app/layout.js"
|
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||||
|
"static/css/b12d1381ac64f6da.css",
|
||||||
|
"static/chunks/app/layout-3a83ac2605aef91b.js"
|
||||||
|
],
|
||||||
|
"/(auth)/sectors/page": [
|
||||||
|
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||||
|
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
|
||||||
|
"static/chunks/117-a8e886455f28924b.js",
|
||||||
|
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||||
|
"static/chunks/648-737196aebda2b095.js",
|
||||||
|
"static/chunks/app/(auth)/sectors/page-b1b520bb87096d8c.js"
|
||||||
|
],
|
||||||
|
"/(auth)/layout": [
|
||||||
|
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||||
|
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
|
||||||
|
"static/chunks/117-a8e886455f28924b.js",
|
||||||
|
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||||
|
"static/chunks/648-737196aebda2b095.js",
|
||||||
|
"static/chunks/app/(auth)/layout-895972713478d7d5.js"
|
||||||
|
],
|
||||||
|
"/(auth)/sentiment/page": [
|
||||||
|
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||||
|
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
|
||||||
|
"static/chunks/117-a8e886455f28924b.js",
|
||||||
|
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||||
|
"static/chunks/app/(auth)/sentiment/page-914adbd9eac71003.js"
|
||||||
|
],
|
||||||
|
"/(auth)/sectors/[name]/page": [
|
||||||
|
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||||
|
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
|
||||||
|
"static/chunks/117-a8e886455f28924b.js",
|
||||||
|
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||||
|
"static/chunks/648-737196aebda2b095.js",
|
||||||
|
"static/chunks/app/(auth)/sectors/[name]/page-b47ed772b7ed0b86.js"
|
||||||
|
],
|
||||||
|
"/(auth)/chat/page": [
|
||||||
|
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||||
|
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
|
||||||
|
"static/chunks/117-a8e886455f28924b.js",
|
||||||
|
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||||
|
"static/chunks/app/(auth)/chat/page-2460c0ba93c793ad.js"
|
||||||
|
],
|
||||||
|
"/(auth)/dashboard/page": [
|
||||||
|
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||||
|
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
|
||||||
|
"static/chunks/117-a8e886455f28924b.js",
|
||||||
|
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||||
|
"static/chunks/app/(auth)/dashboard/page-7ff91dee1ef2fdbc.js"
|
||||||
|
],
|
||||||
|
"/(auth)/watchlists/page": [
|
||||||
|
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||||
|
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
|
||||||
|
"static/chunks/117-a8e886455f28924b.js",
|
||||||
|
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||||
|
"static/chunks/app/(auth)/watchlists/page-d8baeb97cf398976.js"
|
||||||
|
],
|
||||||
|
"/(auth)/recommendations/page": [
|
||||||
|
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||||
|
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
|
||||||
|
"static/chunks/117-a8e886455f28924b.js",
|
||||||
|
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||||
|
"static/chunks/app/(auth)/recommendations/page-7a67286c61a1d8d0.js"
|
||||||
|
],
|
||||||
|
"/(auth)/stock/[code]/page": [
|
||||||
|
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||||
|
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
|
||||||
|
"static/chunks/117-a8e886455f28924b.js",
|
||||||
|
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||||
|
"static/chunks/app/(auth)/stock/[code]/page-5a76132c106da8d3.js"
|
||||||
|
],
|
||||||
|
"/(public)/login/page": [
|
||||||
|
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||||
|
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
|
||||||
|
"static/chunks/117-a8e886455f28924b.js",
|
||||||
|
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||||
|
"static/chunks/app/(public)/login/page-a72b3e6424d449e4.js"
|
||||||
|
],
|
||||||
|
"/(public)/layout": [
|
||||||
|
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||||
|
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
|
||||||
|
"static/chunks/117-a8e886455f28924b.js",
|
||||||
|
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||||
|
"static/chunks/app/(public)/layout-d8307c143f9d6e89.js"
|
||||||
|
],
|
||||||
|
"/(public)/page": [
|
||||||
|
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||||
|
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
|
||||||
|
"static/chunks/117-a8e886455f28924b.js",
|
||||||
|
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||||
|
"static/chunks/648-737196aebda2b095.js",
|
||||||
|
"static/chunks/app/(public)/page-6a015fcfb504f889.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,19 +1,32 @@
|
|||||||
{
|
{
|
||||||
"polyfillFiles": [
|
"polyfillFiles": [
|
||||||
"static/chunks/polyfills.js"
|
"static/chunks/polyfills-42372ed130431b0a.js"
|
||||||
],
|
],
|
||||||
"devFiles": [],
|
"devFiles": [],
|
||||||
"ampDevFiles": [],
|
"ampDevFiles": [],
|
||||||
"lowPriorityFiles": [
|
"lowPriorityFiles": [
|
||||||
"static/development/_buildManifest.js",
|
"static/ENQu76Djfgx3eox8nyd0C/_buildManifest.js",
|
||||||
"static/development/_ssgManifest.js"
|
"static/ENQu76Djfgx3eox8nyd0C/_ssgManifest.js"
|
||||||
],
|
],
|
||||||
"rootMainFiles": [
|
"rootMainFiles": [
|
||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||||
"static/chunks/main-app.js"
|
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
|
||||||
|
"static/chunks/117-a8e886455f28924b.js",
|
||||||
|
"static/chunks/main-app-7d7e5d1021afd90c.js"
|
||||||
],
|
],
|
||||||
"pages": {
|
"pages": {
|
||||||
"/_app": []
|
"/_app": [
|
||||||
|
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||||
|
"static/chunks/framework-f66176bb897dc684.js",
|
||||||
|
"static/chunks/main-728cc0b2064d905a.js",
|
||||||
|
"static/chunks/pages/_app-72b849fbd24ac258.js"
|
||||||
|
],
|
||||||
|
"/_error": [
|
||||||
|
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||||
|
"static/chunks/framework-f66176bb897dc684.js",
|
||||||
|
"static/chunks/main-728cc0b2064d905a.js",
|
||||||
|
"static/chunks/pages/_error-7ba65e1336b92748.js"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"ampFirstPages": []
|
"ampFirstPages": []
|
||||||
}
|
}
|
||||||
@ -1 +1,14 @@
|
|||||||
{}
|
{
|
||||||
|
"components/capital-flow.tsx -> echarts": {
|
||||||
|
"id": 9614,
|
||||||
|
"files": [
|
||||||
|
"static/chunks/614.2cf8795c6fba79f8.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"components/kline-chart.tsx -> echarts": {
|
||||||
|
"id": 9614,
|
||||||
|
"files": [
|
||||||
|
"static/chunks/614.2cf8795c6fba79f8.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,14 @@
|
|||||||
{
|
{
|
||||||
"/(auth)/recommendations/page": "app/(auth)/recommendations/page.js"
|
"/_not-found/page": "app/_not-found/page.js",
|
||||||
|
"/(auth)/sectors/page": "app/(auth)/sectors/page.js",
|
||||||
|
"/(auth)/sentiment/page": "app/(auth)/sentiment/page.js",
|
||||||
|
"/(auth)/sectors/[name]/page": "app/(auth)/sectors/[name]/page.js",
|
||||||
|
"/(auth)/chat/page": "app/(auth)/chat/page.js",
|
||||||
|
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js",
|
||||||
|
"/(auth)/api/chat/stream/route": "app/(auth)/api/chat/stream/route.js",
|
||||||
|
"/(auth)/watchlists/page": "app/(auth)/watchlists/page.js",
|
||||||
|
"/(auth)/recommendations/page": "app/(auth)/recommendations/page.js",
|
||||||
|
"/(auth)/stock/[code]/page": "app/(auth)/stock/[code]/page.js",
|
||||||
|
"/(public)/login/page": "app/(public)/login/page.js",
|
||||||
|
"/(public)/page": "app/(public)/page.js"
|
||||||
}
|
}
|
||||||
@ -1 +1 @@
|
|||||||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
|
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]";
|
||||||
@ -1,21 +1 @@
|
|||||||
self.__BUILD_MANIFEST = {
|
self.__BUILD_MANIFEST={polyfillFiles:["static/chunks/polyfills-42372ed130431b0a.js"],devFiles:[],ampDevFiles:[],lowPriorityFiles:[],rootMainFiles:["static/chunks/webpack-76aa9cbbdedb6a49.js","static/chunks/fd9d1056-04f4ef6e08ec3462.js","static/chunks/117-a8e886455f28924b.js","static/chunks/main-app-7d7e5d1021afd90c.js"],pages:{"/_app":["static/chunks/webpack-76aa9cbbdedb6a49.js","static/chunks/framework-f66176bb897dc684.js","static/chunks/main-728cc0b2064d905a.js","static/chunks/pages/_app-72b849fbd24ac258.js"],"/_error":["static/chunks/webpack-76aa9cbbdedb6a49.js","static/chunks/framework-f66176bb897dc684.js","static/chunks/main-728cc0b2064d905a.js","static/chunks/pages/_error-7ba65e1336b92748.js"]},ampFirstPages:[]},self.__BUILD_MANIFEST.lowPriorityFiles=["/static/"+process.env.__NEXT_BUILD_ID+"/_buildManifest.js",,"/static/"+process.env.__NEXT_BUILD_ID+"/_ssgManifest.js"];
|
||||||
"polyfillFiles": [
|
|
||||||
"static/chunks/polyfills.js"
|
|
||||||
],
|
|
||||||
"devFiles": [],
|
|
||||||
"ampDevFiles": [],
|
|
||||||
"lowPriorityFiles": [],
|
|
||||||
"rootMainFiles": [
|
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main-app.js"
|
|
||||||
],
|
|
||||||
"pages": {
|
|
||||||
"/_app": []
|
|
||||||
},
|
|
||||||
"ampFirstPages": []
|
|
||||||
};
|
|
||||||
self.__BUILD_MANIFEST.lowPriorityFiles = [
|
|
||||||
"/static/" + process.env.__NEXT_BUILD_ID + "/_buildManifest.js",
|
|
||||||
,"/static/" + process.env.__NEXT_BUILD_ID + "/_ssgManifest.js",
|
|
||||||
|
|
||||||
];
|
|
||||||
@ -1 +1 @@
|
|||||||
self.__REACT_LOADABLE_MANIFEST="{}"
|
self.__REACT_LOADABLE_MANIFEST='{"components/capital-flow.tsx -> echarts":{"id":9614,"files":["static/chunks/614.2cf8795c6fba79f8.js"]},"components/kline-chart.tsx -> echarts":{"id":9614,"files":["static/chunks/614.2cf8795c6fba79f8.js"]}}';
|
||||||
@ -1 +1 @@
|
|||||||
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"
|
self.__NEXT_FONT_MANIFEST='{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}';
|
||||||
@ -1 +1 @@
|
|||||||
{}
|
{"/_app":"pages/_app.js","/_error":"pages/_error.js","/_document":"pages/_document.js","/404":"pages/404.html"}
|
||||||
@ -1 +1 @@
|
|||||||
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY\"\n}"
|
self.__RSC_SERVER_MANIFEST="{\"node\":{},\"edge\":{},\"encryptionKey\":\"process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY\"}"
|
||||||
@ -1,5 +1 @@
|
|||||||
{
|
{"node":{},"edge":{},"encryptionKey":"Y+iTYVqqfu0PTKokbxxh1ZopwesJ4kb+sPOCvZ1fElY="}
|
||||||
"node": {},
|
|
||||||
"edge": {},
|
|
||||||
"encryptionKey": "d4VN2R1pidVsk7vdfLIPEMxazAw72u3gfaTtAbGckJc="
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
259
frontend/src/app/(auth)/sectors/[name]/page.tsx
Normal file
259
frontend/src/app/(auth)/sectors/[name]/page.tsx
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { fetchAPI, type SectorDetailResponse, type SectorMemberCandidate } from "@/lib/api";
|
||||||
|
import { formatNumber } from "@/lib/utils";
|
||||||
|
|
||||||
|
type MemberFilter = "all" | "actionable" | "watch" | "observe";
|
||||||
|
|
||||||
|
function stageLabel(stage?: string) {
|
||||||
|
const map: Record<string, string> = { early: "启动期", mid: "发展期", late: "后期", end: "尾声", intraday: "盘中" };
|
||||||
|
return map[stage || ""] || "未分层";
|
||||||
|
}
|
||||||
|
|
||||||
|
function signalLabel(signal?: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
breakout: "突破",
|
||||||
|
breakout_confirm: "确认",
|
||||||
|
pullback: "回踩",
|
||||||
|
launch: "启动",
|
||||||
|
reversal: "反转",
|
||||||
|
flow_momentum: "资金",
|
||||||
|
none: "无信号",
|
||||||
|
};
|
||||||
|
return map[signal || ""] || signal || "无信号";
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionTone(action: string) {
|
||||||
|
if (action === "可操作") return "border-red-500/15 bg-red-500/[0.06] text-red-300";
|
||||||
|
if (action === "重点关注") return "border-amber-500/15 bg-amber-500/[0.07] text-amber-300";
|
||||||
|
return "border-border-subtle bg-surface-2 text-text-muted";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SectorDetailPage() {
|
||||||
|
const params = useParams<{ name: string }>();
|
||||||
|
const sectorName = decodeURIComponent(params.name || "");
|
||||||
|
const [data, setData] = useState<SectorDetailResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filter, setFilter] = useState<MemberFilter>("all");
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!sectorName) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await fetchAPI<SectorDetailResponse>(`/api/sectors/${encodeURIComponent(sectorName)}/detail`);
|
||||||
|
setData(result);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [sectorName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const sector = data?.sector;
|
||||||
|
const displayPct = sector ? (sector.realtime_pct_change ?? sector.pct_change) : 0;
|
||||||
|
const displayAmount = sector ? (sector.realtime_amount ?? sector.capital_inflow) : 0;
|
||||||
|
const displayLimitUp = sector ? (sector.realtime_limit_up_count ?? sector.limit_up_count) : 0;
|
||||||
|
|
||||||
|
const filteredMembers = useMemo(() => {
|
||||||
|
const members = data?.members || [];
|
||||||
|
if (filter === "actionable") return members.filter((item) => item.action_plan === "可操作");
|
||||||
|
if (filter === "watch") return members.filter((item) => item.action_plan === "重点关注");
|
||||||
|
if (filter === "observe") return members.filter((item) => item.action_plan === "观察");
|
||||||
|
return members;
|
||||||
|
}, [data, filter]);
|
||||||
|
|
||||||
|
const reasonCounts = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
(data?.members || []).forEach((item) => {
|
||||||
|
(item.elimination_reason || "待确认").split(";").forEach((reason) => {
|
||||||
|
if (!reason) return;
|
||||||
|
counts[reason] = (counts[reason] || 0) + 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 6);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||||
|
<Link href="/sectors" className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-primary transition-colors">
|
||||||
|
← 返回板块主线
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<header className="glass-card-static p-4 md:p-5">
|
||||||
|
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">Theme Desk</div>
|
||||||
|
<h1 className="mt-2 text-2xl font-bold tracking-tight text-text-primary">{sector?.sector_name || sectorName}</h1>
|
||||||
|
<p className="mt-2 max-w-3xl text-sm leading-6 text-text-secondary">
|
||||||
|
{sector?.catalyst_reasons?.[0] || "最近扫描沉淀的板块成分候选,按主题、资金、角色和入场时机排序。"}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Badge>{stageLabel(sector?.stage)}</Badge>
|
||||||
|
<Badge>扫描 {data?.scan_session || "-"}</Badge>
|
||||||
|
<Badge>{sector?.data_mode || "local"}</Badge>
|
||||||
|
{(sector?.theme_aliases || []).slice(0, 4).map((alias) => <Badge key={alias}>{alias}</Badge>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Metric label="涨跌幅" value={`${displayPct > 0 ? "+" : ""}${displayPct.toFixed(2)}%`} tone={displayPct >= 0 ? "up" : "down"} />
|
||||||
|
<Metric label="热度" value={sector?.heat_score?.toFixed(0) || "-"} />
|
||||||
|
<Metric label="资金" value={`${displayAmount >= 0 ? "+" : ""}${formatNumber(displayAmount)}`} tone={displayAmount >= 0 ? "up" : "down"} />
|
||||||
|
<Metric label="涨停" value={`${displayLimitUp}只`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||||
|
<Summary label="候选成分" value={data?.summary.member_count ?? 0} />
|
||||||
|
<Summary label="可操作" value={data?.summary.actionable_count ?? 0} tone="red" />
|
||||||
|
<Summary label="重点关注" value={data?.summary.watch_count ?? 0} tone="amber" />
|
||||||
|
<Summary label="观察" value={data?.summary.observe_count ?? 0} />
|
||||||
|
<Summary label="均分" value={data?.summary.avg_score ?? 0} tone="amber" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 gap-5 xl:grid-cols-[280px_1fr]">
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<div className="glass-card-static p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">快速筛选</h2>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
{[
|
||||||
|
{ key: "all", label: "全部", count: data?.members.length || 0 },
|
||||||
|
{ key: "actionable", label: "可操作", count: data?.summary.actionable_count || 0 },
|
||||||
|
{ key: "watch", label: "关注", count: data?.summary.watch_count || 0 },
|
||||||
|
{ key: "observe", label: "观察", count: data?.summary.observe_count || 0 },
|
||||||
|
].map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => setFilter(item.key as MemberFilter)}
|
||||||
|
className={`rounded-xl border px-3 py-2 text-left transition-all ${
|
||||||
|
filter === item.key
|
||||||
|
? "border-amber-500/20 bg-amber-500/[0.07] text-amber-400"
|
||||||
|
: "border-border-subtle bg-surface-2 text-text-muted hover:text-text-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-semibold">{item.label}</div>
|
||||||
|
<div className="mt-1 font-mono text-[11px] tabular-nums">{item.count}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-card-static p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">过滤原因</h2>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{reasonCounts.length ? reasonCounts.map(([reason, count]) => (
|
||||||
|
<div key={reason} className="flex items-center justify-between gap-3 rounded-lg bg-surface-2 px-3 py-2">
|
||||||
|
<span className="truncate text-[11px] text-text-muted">{reason}</span>
|
||||||
|
<span className="font-mono text-xs tabular-nums text-text-secondary">{count}</span>
|
||||||
|
</div>
|
||||||
|
)) : <div className="text-xs text-text-muted">暂无记录</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="glass-card-static overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">成分候选</h2>
|
||||||
|
<button onClick={loadData} className="rounded-lg border border-border-subtle bg-surface-2 px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary">
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
{[1, 2, 3, 4].map((item) => <div key={item} className="h-24 animate-shimmer rounded-xl bg-surface-2" />)}
|
||||||
|
</div>
|
||||||
|
) : filteredMembers.length === 0 ? (
|
||||||
|
<div className="p-10 text-center text-sm text-text-muted">暂无成分候选记录</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border-subtle">
|
||||||
|
{filteredMembers.map((member) => <MemberRow key={member.ts_code} member={member} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemberRow({ member }: { member: SectorMemberCandidate }) {
|
||||||
|
return (
|
||||||
|
<article className="grid gap-4 px-4 py-4 xl:grid-cols-[180px_88px_1fr_300px] xl:items-center">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Link href={`/stock/${member.ts_code}`} className="truncate text-sm font-semibold text-text-primary hover:text-amber-300">
|
||||||
|
{member.name}
|
||||||
|
</Link>
|
||||||
|
<div className="mt-1 font-mono text-[10px] text-text-muted">{member.ts_code}</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
<span className={`rounded-md border px-2 py-0.5 text-[10px] ${actionTone(member.action_plan)}`}>{member.action_plan}</span>
|
||||||
|
<span className="rounded-md border border-border-subtle bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">{member.stock_role || "候选"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-2xl font-bold tabular-nums text-amber-400">{member.final_score.toFixed(1)}</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<SmallStat label="主力" value={`${formatNumber(member.main_net_inflow)}`} />
|
||||||
|
<SmallStat label="流入比" value={`${Number(member.inflow_ratio || 0).toFixed(1)}%`} />
|
||||||
|
<SmallStat label="换手" value={`${Number(member.turnover_rate || 0).toFixed(1)}%`} />
|
||||||
|
<SmallStat label="信号" value={signalLabel(member.entry_signal_type)} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs leading-5 text-text-muted line-clamp-2">
|
||||||
|
{member.action_plan === "观察" ? member.elimination_reason || "等待资金和时机确认" : member.trigger_condition || "等待触发条件"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-5 gap-1.5">
|
||||||
|
<Score label="催化" value={member.catalyst_score} />
|
||||||
|
<Score label="题材" value={member.theme_money_score} />
|
||||||
|
<Score label="资金" value={member.stock_money_score} />
|
||||||
|
<Score label="角色" value={member.emotion_role_score} />
|
||||||
|
<Score label="时机" value={member.timing_score} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({ children }: { children: React.ReactNode }) {
|
||||||
|
return <span className="rounded-lg border border-border-subtle bg-surface-2 px-2 py-1 text-[10px] text-text-muted">{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value, tone = "default" }: { label: string; value: string; tone?: "default" | "up" | "down" }) {
|
||||||
|
const color = tone === "up" ? "text-red-400" : tone === "down" ? "text-emerald-400" : "text-text-primary";
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-surface-1 px-3 py-3">
|
||||||
|
<div className="text-[10px] text-text-muted">{label}</div>
|
||||||
|
<div className={`mt-1 font-mono text-sm font-bold tabular-nums ${color}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Summary({ label, value, tone = "default" }: { label: string; value: string | number; tone?: "default" | "red" | "amber" }) {
|
||||||
|
const color = tone === "red" ? "text-red-400" : tone === "amber" ? "text-amber-400" : "text-text-primary";
|
||||||
|
return (
|
||||||
|
<div className="glass-card-static p-4">
|
||||||
|
<div className="text-[10px] text-text-muted">{label}</div>
|
||||||
|
<div className={`mt-2 font-mono text-xl font-bold tabular-nums ${color}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SmallStat({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<span className="rounded-lg bg-surface-2 px-2 py-1 text-[10px] text-text-secondary">
|
||||||
|
<span className="text-text-muted">{label}</span> {value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Score({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-surface-2 px-2 py-1.5 text-center">
|
||||||
|
<div className="text-[9px] text-text-muted/60">{label}</div>
|
||||||
|
<div className="mt-0.5 font-mono text-[11px] font-semibold tabular-nums text-text-secondary">{Math.round(value)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import type { LeadingStock, SectorData } from "@/lib/api";
|
|||||||
import { formatNumber } from "@/lib/utils";
|
import { formatNumber } from "@/lib/utils";
|
||||||
import { ErrorBoundary } from "@/components/error-boundary";
|
import { ErrorBoundary } from "@/components/error-boundary";
|
||||||
import { useWebSocket } from "@/hooks/use-websocket";
|
import { useWebSocket } from "@/hooks/use-websocket";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
function getThemeAliasLine(sector: SectorData) {
|
function getThemeAliasLine(sector: SectorData) {
|
||||||
const aliases = (sector.theme_aliases ?? []).filter((alias) => alias && alias !== sector.sector_name).slice(0, 4);
|
const aliases = (sector.theme_aliases ?? []).filter((alias) => alias && alias !== sector.sector_name).slice(0, 4);
|
||||||
@ -215,7 +216,7 @@ function LaneRow({ sector, rank }: { sector: SectorData; rank: number }) {
|
|||||||
const catalyst = getCatalystLabel(sector);
|
const catalyst = getCatalystLabel(sector);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border-subtle bg-surface-2/70 px-3 py-3">
|
<Link href={`/sectors/${encodeURIComponent(sector.sector_name)}`} className="block rounded-xl border border-border-subtle bg-surface-2/70 px-3 py-3 transition-colors hover:border-amber-500/20 hover:bg-surface-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex gap-3">
|
<div className="min-w-0 flex gap-3">
|
||||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-surface-1 font-mono text-xs text-text-muted">
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-surface-1 font-mono text-xs text-text-muted">
|
||||||
@ -261,7 +262,7 @@ function LaneRow({ sector, rank }: { sector: SectorData; rank: number }) {
|
|||||||
失效信号:{action.risk}
|
失效信号:{action.risk}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,11 +277,13 @@ function SectorCard({ sector, index }: { sector: SectorData; index: number }) {
|
|||||||
const catalystReason = sector.catalyst_reasons?.[0];
|
const catalystReason = sector.catalyst_reasons?.[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-3 animate-fade-in-up" style={{ animationDelay: `${index * 30}ms` }}>
|
<div className="rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-3 animate-fade-in-up transition-colors hover:border-amber-500/20 hover:bg-surface-2/70" style={{ animationDelay: `${index * 30}ms` }}>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-sm font-semibold text-text-primary">{sector.sector_name}</span>
|
<Link href={`/sectors/${encodeURIComponent(sector.sector_name)}`} className="text-sm font-semibold text-text-primary hover:text-amber-300">
|
||||||
|
{sector.sector_name}
|
||||||
|
</Link>
|
||||||
{sector.board_type === "theme" ? (
|
{sector.board_type === "theme" ? (
|
||||||
<span className="rounded-md border border-sky-500/15 bg-sky-500/10 px-1.5 py-0.5 text-[10px] text-sky-300">
|
<span className="rounded-md border border-sky-500/15 bg-sky-500/10 px-1.5 py-0.5 text-[10px] text-sky-300">
|
||||||
主题
|
主题
|
||||||
@ -337,6 +340,10 @@ function SectorCard({ sector, index }: { sector: SectorData; index: number }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Link href={`/sectors/${encodeURIComponent(sector.sector_name)}`} className="mt-3 inline-flex rounded-lg border border-amber-500/15 bg-amber-500/[0.05] px-3 py-1.5 text-xs font-medium text-amber-400 hover:bg-amber-500/[0.08]">
|
||||||
|
查看成分候选
|
||||||
|
</Link>
|
||||||
|
|
||||||
{leaders.length ? (
|
{leaders.length ? (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="text-[10px] uppercase tracking-wider text-text-muted font-semibold mb-2">代表股</div>
|
<div className="text-[10px] uppercase tracking-wider text-text-muted font-semibold mb-2">代表股</div>
|
||||||
|
|||||||
@ -255,6 +255,45 @@ export interface SectorData {
|
|||||||
catalyst_reasons?: string[];
|
catalyst_reasons?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SectorMemberCandidate {
|
||||||
|
ts_code: string;
|
||||||
|
name: string;
|
||||||
|
theme_name: string;
|
||||||
|
stock_role: string;
|
||||||
|
action_plan: string;
|
||||||
|
final_score: number;
|
||||||
|
catalyst_score: number;
|
||||||
|
theme_money_score: number;
|
||||||
|
stock_money_score: number;
|
||||||
|
emotion_role_score: number;
|
||||||
|
timing_score: number;
|
||||||
|
entry_signal_type: string;
|
||||||
|
elimination_reason: string;
|
||||||
|
recall_tags: string[];
|
||||||
|
main_net_inflow: number;
|
||||||
|
inflow_ratio: number;
|
||||||
|
turnover_rate: number;
|
||||||
|
volume_ratio?: number | null;
|
||||||
|
trigger_condition: string;
|
||||||
|
invalidation_condition: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectorDetailResponse {
|
||||||
|
sector: SectorData | null;
|
||||||
|
scan_session: string;
|
||||||
|
members: SectorMemberCandidate[];
|
||||||
|
action_counts: Record<string, number>;
|
||||||
|
top_candidates: SectorMemberCandidate[];
|
||||||
|
summary: {
|
||||||
|
member_count: number;
|
||||||
|
actionable_count: number;
|
||||||
|
watch_count: number;
|
||||||
|
observe_count: number;
|
||||||
|
avg_score: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface CatalystNewsItem {
|
export interface CatalystNewsItem {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user