1
This commit is contained in:
parent
153dcc2f70
commit
767c80d387
@ -86,6 +86,7 @@ async def get_latest():
|
||||
"market_anomalies": anomalies,
|
||||
"scan_mode": result.get("scan_mode", "unknown"),
|
||||
"strategy_profile": result.get("strategy_profile"),
|
||||
"latest_scan": result.get("latest_scan"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -85,6 +85,61 @@ def _build_legacy_decision_trace(row) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _scan_meta_from_row(row) -> dict | None:
|
||||
if not row:
|
||||
return None
|
||||
r = row._mapping if hasattr(row, "_mapping") else row
|
||||
detail = _safe_json_dict(r.get("detail_json"))
|
||||
return {
|
||||
"scan_session": r.get("scan_session") or "",
|
||||
"scan_mode": r.get("scan_mode") or "",
|
||||
"status": r.get("status") or "ok",
|
||||
"stage": r.get("stage") or "",
|
||||
"input_count": int(r.get("input_count") or 0),
|
||||
"output_count": int(r.get("output_count") or 0),
|
||||
"filtered_count": int(r.get("filtered_count") or 0),
|
||||
"summary": r.get("summary") or "",
|
||||
"created_at": str(r.get("created_at") or ""),
|
||||
"date": str(r.get("created_at") or "")[:10] if r.get("created_at") else "",
|
||||
"detail": detail,
|
||||
"action_counts": detail.get("action_counts") or {},
|
||||
"elimination_reasons": detail.get("elimination_reasons") or {},
|
||||
}
|
||||
|
||||
|
||||
async def _load_latest_completed_scan(db) -> dict | None:
|
||||
from sqlalchemy import text
|
||||
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT * FROM scan_process_logs "
|
||||
"WHERE stage = 'final_filter' "
|
||||
"ORDER BY created_at DESC, id DESC LIMIT 1"
|
||||
)
|
||||
)
|
||||
return _scan_meta_from_row(result.fetchone())
|
||||
|
||||
|
||||
async def _load_latest_strategy_profile_for_session(db, scan_session: str) -> dict | None:
|
||||
if not scan_session:
|
||||
return None
|
||||
from sqlalchemy import text
|
||||
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT detail_json FROM scan_process_logs "
|
||||
"WHERE scan_session = :session AND stage = 'strategy_profile' "
|
||||
"ORDER BY created_at DESC, id DESC LIMIT 1"
|
||||
),
|
||||
{"session": scan_session},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
profile = _safe_json_dict(row._mapping.get("detail_json"))
|
||||
return profile or None
|
||||
|
||||
|
||||
async def refresh_recommendations(trade_date: str = None, scan_session: str = "manual") -> dict:
|
||||
"""刷新推荐列表(带扫描锁防止并发)"""
|
||||
global _scan_running
|
||||
@ -577,6 +632,25 @@ async def get_recommendation_history(days: int = 7) -> list[dict]:
|
||||
async with get_db() as db:
|
||||
from sqlalchemy import text
|
||||
|
||||
scan_result = await db.execute(
|
||||
text(
|
||||
"SELECT s.* FROM scan_process_logs s "
|
||||
"INNER JOIN ("
|
||||
" SELECT date(created_at) AS scan_date, MAX(id) AS max_id "
|
||||
" FROM scan_process_logs "
|
||||
" WHERE stage = 'final_filter' AND created_at >= :start "
|
||||
" GROUP BY date(created_at)"
|
||||
") latest ON s.id = latest.max_id "
|
||||
"ORDER BY s.created_at DESC, s.id DESC"
|
||||
),
|
||||
{"start": start},
|
||||
)
|
||||
scan_groups = {
|
||||
meta["date"]: meta
|
||||
for meta in (_scan_meta_from_row(row) for row in scan_result.fetchall())
|
||||
if meta and meta.get("date")
|
||||
}
|
||||
|
||||
# 查询所有历史推荐,按 ts_code 去重(每天取最新一条)
|
||||
stmt = text(
|
||||
"SELECT r.*, "
|
||||
@ -614,8 +688,11 @@ async def get_recommendation_history(days: int = 7) -> list[dict]:
|
||||
result = await db.execute(stmt, {"start": start})
|
||||
rows = result.fetchall()
|
||||
|
||||
# 按日期分组
|
||||
grouped: dict[str, list[dict]] = {}
|
||||
# 按日期分组。先用扫描日志建组,保证“当天扫描但无推荐”也会被展示。
|
||||
grouped: dict[str, dict] = {
|
||||
date_str: {"scan": scan_meta, "recommendations": []}
|
||||
for date_str, scan_meta in scan_groups.items()
|
||||
}
|
||||
for row in rows:
|
||||
r = row._mapping
|
||||
# SQLite created_at 是字符串 "YYYY-MM-DD HH:MM:SS"
|
||||
@ -679,13 +756,15 @@ async def get_recommendation_history(days: int = 7) -> list[dict]:
|
||||
}
|
||||
|
||||
if date_str not in grouped:
|
||||
grouped[date_str] = []
|
||||
grouped[date_str].append(rec_dict)
|
||||
grouped[date_str] = {"scan": None, "recommendations": []}
|
||||
grouped[date_str]["recommendations"].append(rec_dict)
|
||||
|
||||
# 转为列表,按日期降序
|
||||
result_list = []
|
||||
for date_str in sorted(grouped.keys(), reverse=True):
|
||||
recs = grouped[date_str]
|
||||
group = grouped[date_str]
|
||||
recs = group["recommendations"]
|
||||
scan_meta = group["scan"]
|
||||
buy_count = sum(1 for r in recs if r["signal"] == "BUY")
|
||||
avg_score = round(sum(r["score"] for r in recs) / len(recs), 1) if recs else 0
|
||||
result_list.append({
|
||||
@ -694,6 +773,15 @@ async def get_recommendation_history(days: int = 7) -> list[dict]:
|
||||
"buy_count": buy_count,
|
||||
"avg_score": avg_score,
|
||||
"recommendations": recs,
|
||||
"scan_session": scan_meta.get("scan_session") if scan_meta else "",
|
||||
"scan_mode": scan_meta.get("scan_mode") if scan_meta else "",
|
||||
"scan_status": scan_meta.get("status") if scan_meta else "",
|
||||
"scanned_at": scan_meta.get("created_at") if scan_meta else "",
|
||||
"scan_summary": scan_meta.get("summary") if scan_meta else "",
|
||||
"scan_input_count": scan_meta.get("input_count") if scan_meta else 0,
|
||||
"scan_output_count": scan_meta.get("output_count") if scan_meta else len(recs),
|
||||
"scan_filtered_count": scan_meta.get("filtered_count") if scan_meta else 0,
|
||||
"elimination_reasons": scan_meta.get("elimination_reasons") if scan_meta else {},
|
||||
})
|
||||
|
||||
return result_list
|
||||
@ -908,6 +996,10 @@ async def _load_today_from_db() -> dict:
|
||||
from sqlalchemy import text
|
||||
import json
|
||||
|
||||
latest_scan = await _load_latest_completed_scan(db)
|
||||
latest_scan_date = (latest_scan or {}).get("date") or ""
|
||||
latest_scan_session = (latest_scan or {}).get("scan_session") or ""
|
||||
|
||||
# 加载市场温度(按 trade_date 取最新交易日)
|
||||
result = await db.execute(
|
||||
text(
|
||||
@ -931,7 +1023,32 @@ async def _load_today_from_db() -> dict:
|
||||
temperature=m["temperature"],
|
||||
)
|
||||
|
||||
# 加载推荐(取最近一个有数据的日期,按 ts_code 去重,优先保留行动级别更高的结果)
|
||||
if latest_scan:
|
||||
recommendation_sql = (
|
||||
"SELECT * FROM recommendations "
|
||||
"WHERE date(created_at) = :target_date "
|
||||
"AND (:scan_session = '' OR scan_session = :scan_session) "
|
||||
"AND (action_plan IN ('可操作', '重点关注') OR score >= 56) "
|
||||
"AND ("
|
||||
" COALESCE(recall_tags, '[]') LIKE '%hot_theme_core%' "
|
||||
" OR COALESCE(recall_tags, '[]') LIKE '%theme_leader%' "
|
||||
" OR COALESCE(recall_tags, '[]') LIKE '%top_theme_member%' "
|
||||
" OR COALESCE(recall_tags, '[]') LIKE '%sector_recall%'"
|
||||
") "
|
||||
"AND id IN ("
|
||||
" SELECT MAX(id) FROM recommendations "
|
||||
" WHERE date(created_at) = :target_date "
|
||||
" AND (:scan_session = '' OR scan_session = :scan_session) "
|
||||
" GROUP BY ts_code"
|
||||
") "
|
||||
"ORDER BY score DESC"
|
||||
)
|
||||
result = await db.execute(
|
||||
text(recommendation_sql),
|
||||
{"target_date": latest_scan_date, "scan_session": latest_scan_session},
|
||||
)
|
||||
else:
|
||||
# 兼容旧库:没有扫描日志时,才回退到最近一个有推荐的日期。
|
||||
result = await db.execute(
|
||||
text("SELECT * FROM recommendations "
|
||||
"WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) "
|
||||
@ -988,24 +1105,29 @@ async def _load_today_from_db() -> dict:
|
||||
focus_points=json.loads(r.get("focus_points") or "[]"),
|
||||
decision_trace=_safe_json_dict(r.get("decision_trace")) or _build_legacy_decision_trace(r),
|
||||
scan_session=r["scan_session"] or "",
|
||||
created_at=r["created_at"],
|
||||
))
|
||||
|
||||
strategy_profile = None
|
||||
if latest_scan:
|
||||
strategy_profile = await _load_latest_strategy_profile_for_session(db, latest_scan_session)
|
||||
if not strategy_profile and recommendations:
|
||||
strategy_profile = get_strategy_profile_by_id(recommendations[0].strategy).model_dump()
|
||||
|
||||
return {
|
||||
"market_temp": market_temp,
|
||||
"hot_sectors": [],
|
||||
"capital_filtered": [],
|
||||
"recommendations": recommendations,
|
||||
"strategy_profile": (
|
||||
get_strategy_profile_by_id(recommendations[0].strategy).model_dump()
|
||||
if recommendations
|
||||
else None
|
||||
),
|
||||
"strategy_profile": strategy_profile,
|
||||
"latest_scan": latest_scan,
|
||||
"scan_mode": (latest_scan or {}).get("scan_mode", "unknown"),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"从数据库加载推荐失败: {e}")
|
||||
from app.db.error_logger import log_error
|
||||
await log_error("recommender", f"从数据库加载推荐失败: {e}", detail=traceback.format_exc())
|
||||
return {"market_temp": None, "hot_sectors": [], "capital_filtered": [], "recommendations": []}
|
||||
return {"market_temp": None, "hot_sectors": [], "capital_filtered": [], "recommendations": [], "latest_scan": None}
|
||||
|
||||
|
||||
async def _load_sectors_from_db() -> list[SectorInfo]:
|
||||
|
||||
Binary file not shown.
@ -12,16 +12,15 @@
|
||||
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
|
||||
"static/chunks/117-a8e886455f28924b.js",
|
||||
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||
"static/css/b12d1381ac64f6da.css",
|
||||
"static/chunks/app/layout-3a83ac2605aef91b.js"
|
||||
"static/css/cc34bfc02fe53996.css",
|
||||
"static/chunks/app/layout-9da89db040cc7fe7.js"
|
||||
],
|
||||
"/(auth)/sectors/page": [
|
||||
"/(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/648-737196aebda2b095.js",
|
||||
"static/chunks/app/(auth)/sectors/page-b1b520bb87096d8c.js"
|
||||
"static/chunks/app/(auth)/dashboard/page-7c42cdee67f7ba1f.js"
|
||||
],
|
||||
"/(auth)/layout": [
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
@ -29,14 +28,29 @@
|
||||
"static/chunks/117-a8e886455f28924b.js",
|
||||
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||
"static/chunks/648-737196aebda2b095.js",
|
||||
"static/chunks/app/(auth)/layout-895972713478d7d5.js"
|
||||
"static/chunks/app/(auth)/layout-00c8b5ffc11909fc.js"
|
||||
],
|
||||
"/(auth)/sentiment/page": [
|
||||
"/(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)/sentiment/page-914adbd9eac71003.js"
|
||||
"static/chunks/app/(auth)/recommendations/page-04ad0514ad59d914.js"
|
||||
],
|
||||
"/(auth)/admin/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)/admin/page-d0e73ac1163fe810.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-2ee86107f57d5938.js"
|
||||
],
|
||||
"/(auth)/sectors/[name]/page": [
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
@ -44,49 +58,59 @@
|
||||
"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"
|
||||
"static/chunks/app/(auth)/sectors/[name]/page-f200c8a32f30483d.js"
|
||||
],
|
||||
"/(auth)/chat/page": [
|
||||
"/(auth)/admin/users/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"
|
||||
"static/chunks/648-737196aebda2b095.js",
|
||||
"static/chunks/app/(auth)/admin/users/page-133cb4455ae7ff45.js"
|
||||
],
|
||||
"/(auth)/dashboard/page": [
|
||||
"/(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/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"
|
||||
"static/chunks/648-737196aebda2b095.js",
|
||||
"static/chunks/app/(auth)/sectors/page-af480a7c6de07a7d.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"
|
||||
"static/chunks/app/(auth)/stock/[code]/page-2af464be6f422116.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-23ada87205189134.js"
|
||||
],
|
||||
"/(auth)/admin/invites/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)/admin/invites/page-f0d0e84e699f2a8e.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-5728882e0ff74783.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"
|
||||
"static/chunks/app/(public)/login/page-0b6bac111efdebf4.js"
|
||||
],
|
||||
"/(public)/layout": [
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/ENQu76Djfgx3eox8nyd0C/_buildManifest.js",
|
||||
"static/ENQu76Djfgx3eox8nyd0C/_ssgManifest.js"
|
||||
"static/DIAczInZnAiIi82P18FrS/_buildManifest.js",
|
||||
"static/DIAczInZnAiIi82P18FrS/_ssgManifest.js"
|
||||
],
|
||||
"rootMainFiles": [
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
@ -18,13 +18,13 @@
|
||||
"/_app": [
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
"static/chunks/framework-f66176bb897dc684.js",
|
||||
"static/chunks/main-728cc0b2064d905a.js",
|
||||
"static/chunks/main-b28cc01a35d4249a.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/main-b28cc01a35d4249a.js",
|
||||
"static/chunks/pages/_error-7ba65e1336b92748.js"
|
||||
]
|
||||
},
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
{
|
||||
"/_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)/admin/page": "app/(auth)/admin/page.js",
|
||||
"/(auth)/chat/page": "app/(auth)/chat/page.js",
|
||||
"/(auth)/sectors/[name]/page": "app/(auth)/sectors/[name]/page.js",
|
||||
"/(auth)/admin/users/page": "app/(auth)/admin/users/page.js",
|
||||
"/(auth)/api/chat/stream/route": "app/(auth)/api/chat/stream/route.js",
|
||||
"/(auth)/sectors/page": "app/(auth)/sectors/page.js",
|
||||
"/(auth)/stock/[code]/page": "app/(auth)/stock/[code]/page.js",
|
||||
"/(auth)/sentiment/page": "app/(auth)/sentiment/page.js",
|
||||
"/(auth)/admin/invites/page": "app/(auth)/admin/invites/page.js",
|
||||
"/(auth)/watchlists/page": "app/(auth)/watchlists/page.js",
|
||||
"/(public)/login/page": "app/(public)/login/page.js",
|
||||
"/(public)/page": "app/(public)/page.js"
|
||||
}
|
||||
@ -1 +1 @@
|
||||
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"];
|
||||
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-b28cc01a35d4249a.js","static/chunks/pages/_app-72b849fbd24ac258.js"],"/_error":["static/chunks/webpack-76aa9cbbdedb6a49.js","static/chunks/framework-f66176bb897dc684.js","static/chunks/main-b28cc01a35d4249a.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"];
|
||||
@ -1 +1 @@
|
||||
{"node":{},"edge":{},"encryptionKey":"Y+iTYVqqfu0PTKokbxxh1ZopwesJ4kb+sPOCvZ1fElY="}
|
||||
{"node":{},"edge":{},"encryptionKey":"s1k3DNf5kMTqjFQA+B2LjtdtSv8ZKMACjH1mm7oDr6I="}
|
||||
File diff suppressed because one or more lines are too long
@ -170,6 +170,7 @@ export default function DashboardPage() {
|
||||
}, [clearScanTimeout]);
|
||||
|
||||
const recommendations = data?.recommendations ?? [];
|
||||
const latestScan = data?.latest_scan ?? null;
|
||||
const actionable = recommendations.filter((rec) => rec.action_plan === "可操作");
|
||||
const watch = recommendations.filter((rec) => rec.action_plan === "重点关注");
|
||||
const observe = recommendations.filter((rec) => !["可操作", "重点关注"].includes(rec.action_plan ?? ""));
|
||||
@ -185,6 +186,7 @@ export default function DashboardPage() {
|
||||
);
|
||||
|
||||
const focusQueue = actionable.length ? actionable : watch.length ? watch : recommendations;
|
||||
const scanTimeLabel = latestScan?.created_at ? formatScanTime(latestScan.created_at) : "暂无完成扫描";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@ -212,6 +214,9 @@ export default function DashboardPage() {
|
||||
已收盘
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">
|
||||
最近扫描 {scanTimeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -246,6 +251,7 @@ export default function DashboardPage() {
|
||||
actionableCount={actionable.length}
|
||||
watchCount={watch.length}
|
||||
observeCount={observe.length}
|
||||
latestScan={latestScan}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -255,6 +261,7 @@ export default function DashboardPage() {
|
||||
focusQueue={focusQueue.slice(0, 6)}
|
||||
actionableCount={actionable.length}
|
||||
watchCount={watch.length}
|
||||
latestScan={latestScan}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<CompactMarketEvidence
|
||||
@ -287,6 +294,7 @@ function DecisionHero({
|
||||
actionableCount,
|
||||
watchCount,
|
||||
observeCount,
|
||||
latestScan,
|
||||
}: {
|
||||
board: StrategyBoard | null;
|
||||
summary: ReturnType<typeof buildMarketSummary>;
|
||||
@ -297,6 +305,7 @@ function DecisionHero({
|
||||
actionableCount: number;
|
||||
watchCount: number;
|
||||
observeCount: number;
|
||||
latestScan: LatestResult["latest_scan"];
|
||||
}) {
|
||||
const leadingSectors = sectors.slice(0, 3);
|
||||
|
||||
@ -349,14 +358,15 @@ function DecisionHero({
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] font-semibold text-text-secondary">焦点标的</div>
|
||||
<div className="text-[10px] text-text-muted">{latestScan?.created_at ? formatScanTime(latestScan.created_at) : "暂无扫描"}</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2.5">
|
||||
{focusQueue.length ? (
|
||||
focusQueue.map((rec) => <FocusStockCard key={rec.ts_code} rec={rec} />)
|
||||
) : (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-6 text-center text-sm text-text-muted">
|
||||
暂无焦点标的。
|
||||
</div>
|
||||
<EmptyScanResult latestScan={latestScan} compact />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -371,11 +381,13 @@ function OpportunityBoard({
|
||||
focusQueue,
|
||||
actionableCount,
|
||||
watchCount,
|
||||
latestScan,
|
||||
}: {
|
||||
sectors: SectorData[];
|
||||
focusQueue: RecommendationData[];
|
||||
actionableCount: number;
|
||||
watchCount: number;
|
||||
latestScan: LatestResult["latest_scan"];
|
||||
}) {
|
||||
const leadingSectors = sectors.slice(0, 5);
|
||||
|
||||
@ -416,7 +428,9 @@ function OpportunityBoard({
|
||||
{focusQueue.length ? focusQueue.map((rec) => (
|
||||
<LargeFocusStockCard key={rec.ts_code} rec={rec} />
|
||||
)) : (
|
||||
<div className="rounded-2xl bg-surface-2/60 p-8 text-center text-sm text-text-muted md:col-span-2">暂无重点标的</div>
|
||||
<div className="md:col-span-2">
|
||||
<EmptyScanResult latestScan={latestScan} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@ -727,6 +741,32 @@ function LargeFocusStockCard({ rec }: { rec: RecommendationData }) {
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyScanResult({ latestScan, compact = false }: { latestScan: LatestResult["latest_scan"]; compact?: boolean }) {
|
||||
const reasons = latestScan?.elimination_reasons ? Object.entries(latestScan.elimination_reasons).slice(0, 3) : [];
|
||||
return (
|
||||
<div className={`rounded-2xl border border-border-subtle bg-surface-1/70 ${compact ? "p-4" : "p-6"} text-left`}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-text-primary">本次扫描无入选标的</div>
|
||||
<span className="rounded-lg bg-surface-2 px-2 py-1 text-[10px] text-text-muted">
|
||||
{latestScan?.created_at ? formatScanTime(latestScan.created_at) : "未扫描"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-text-secondary">
|
||||
{latestScan?.summary || "最近一次扫描没有保留到作战池,不展示历史标的,避免误导。"}
|
||||
</div>
|
||||
{reasons.length ? (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{reasons.map(([reason, count]) => (
|
||||
<span key={reason} className="rounded-lg bg-surface-2/70 px-2 py-1 text-[10px] text-text-muted">
|
||||
{reason} {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getExecutionMeta(rec: RecommendationData) {
|
||||
const hint = rec.decision_trace?.position_adjustment?.hint ?? rec.decision_trace?.context?.position_hint ?? "neutral";
|
||||
const note = rec.decision_trace?.position_adjustment?.notes?.[0] ?? "";
|
||||
@ -757,6 +797,17 @@ function getExecutionMeta(rec: RecommendationData) {
|
||||
return { label: "", note: "", className: "", textClass: "" };
|
||||
}
|
||||
|
||||
function formatScanTime(value: string) {
|
||||
const normalized = value.includes("T") ? value : value.replace(" ", "T");
|
||||
const date = new Date(normalized);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
const today = new Date();
|
||||
const isToday = date.toDateString() === today.toDateString();
|
||||
const time = date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
||||
if (isToday) return `今日 ${time}`;
|
||||
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function TinyInfo({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-bg-primary/45 px-2 py-1.5">
|
||||
|
||||
@ -26,6 +26,18 @@ function formatDate(dateStr: string): string {
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${weekDays[d.getDay()]}`;
|
||||
}
|
||||
|
||||
function formatScanTime(value?: string): string {
|
||||
if (!value) return "暂无扫描";
|
||||
const normalized = value.includes("T") ? value : value.replace(" ", "T");
|
||||
const d = new Date(normalized);
|
||||
if (Number.isNaN(d.getTime())) return value;
|
||||
const today = new Date();
|
||||
const isToday = d.toDateString() === today.toDateString();
|
||||
const time = d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
||||
if (isToday) return `今日 ${time}`;
|
||||
return d.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
type RecommendationWithDate = RecommendationData & { groupDate: string };
|
||||
|
||||
type FocusTab = "actionable" | "watch" | "observe" | "tracking" | "closed";
|
||||
@ -149,6 +161,7 @@ export default function RecommendationsPage() {
|
||||
|
||||
const todayCount = applyHistoryFilter(dayGroups[0]?.recommendations ?? []).length;
|
||||
const totalCount = dayGroups.reduce((sum, group) => sum + applyHistoryFilter(group.recommendations).length, 0);
|
||||
const latestScanLabel = latest?.latest_scan?.created_at || dayGroups[0]?.scanned_at;
|
||||
const focusSummary = buildFocusSummary({
|
||||
strategyProfile: latest?.strategy_profile ?? null,
|
||||
actionable,
|
||||
@ -209,6 +222,8 @@ export default function RecommendationsPage() {
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<SummaryFact label="历史记录" value={`${totalCount} 只`} />
|
||||
<SummaryFact label="已复盘" value={`${closed.length} 只`} />
|
||||
<SummaryFact label="最近扫描" value={formatScanTime(latestScanLabel)} />
|
||||
<SummaryFact label="扫描状态" value={latest?.latest_scan?.status || dayGroups[0]?.scan_status || "暂无"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -312,10 +327,10 @@ export default function RecommendationsPage() {
|
||||
<div className="mt-4 space-y-3">
|
||||
{dayGroups.map((group, index) => {
|
||||
const filtered = applyHistoryFilter(group.recommendations);
|
||||
if (historyFilter !== "all" && filtered.length === 0) return null;
|
||||
|
||||
const isExpanded = expandedDays.has(group.date);
|
||||
const isToday = index === 0;
|
||||
const reasons = group.elimination_reasons ? Object.entries(group.elimination_reasons).slice(0, 3) : [];
|
||||
|
||||
return (
|
||||
<div key={group.date} className="rounded-2xl border border-border-subtle bg-surface-1/40">
|
||||
@ -331,18 +346,44 @@ export default function RecommendationsPage() {
|
||||
<span className="text-[11px] text-text-muted font-mono tabular-nums">{group.date}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-text-muted">
|
||||
{filtered.length} 只推荐
|
||||
扫描 {formatScanTime(group.scanned_at)} · {filtered.length} 只入选
|
||||
{group.scan_input_count ? ` / 候选 ${group.scan_input_count}` : ""}
|
||||
</div>
|
||||
{group.scan_summary ? (
|
||||
<div className="mt-1 line-clamp-1 text-[11px] text-text-secondary">{group.scan_summary}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-xs text-text-muted">{isExpanded ? "收起" : "展开"}</span>
|
||||
</button>
|
||||
|
||||
{isExpanded ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 border-t border-border-subtle px-4 py-4">
|
||||
<div className="border-t border-border-subtle px-4 py-4">
|
||||
{filtered.length ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{filtered.map((rec) => (
|
||||
<StockCard key={`${group.date}-${rec.ts_code}`} rec={rec} compact />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/60 p-5 text-sm text-text-muted">
|
||||
<div className="font-medium text-text-secondary">
|
||||
{historyFilter === "all" ? "本日扫描无入选标的" : "本日扫描存在,但当前筛选条件下无标的"}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5">
|
||||
{group.scan_summary || "系统已完成扫描,但没有标的进入历史推荐池。"}
|
||||
</div>
|
||||
{reasons.length ? (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{reasons.map(([reason, count]) => (
|
||||
<span key={reason} className="rounded-lg bg-surface-2/70 px-2 py-1 text-[10px] text-text-muted">
|
||||
{reason} {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -365,6 +365,23 @@ export interface LatestResult {
|
||||
feedback_notes?: string[];
|
||||
generated_by?: string;
|
||||
} | null;
|
||||
latest_scan?: ScanMeta | null;
|
||||
scan_mode?: string;
|
||||
}
|
||||
|
||||
export interface ScanMeta {
|
||||
scan_session: string;
|
||||
scan_mode: string;
|
||||
status: string;
|
||||
stage: string;
|
||||
input_count: number;
|
||||
output_count: number;
|
||||
filtered_count: number;
|
||||
summary: string;
|
||||
created_at: string;
|
||||
date: string;
|
||||
action_counts?: Record<string, number>;
|
||||
elimination_reasons?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface DayGroup {
|
||||
@ -373,6 +390,15 @@ export interface DayGroup {
|
||||
buy_count: number;
|
||||
avg_score: number;
|
||||
recommendations: RecommendationData[];
|
||||
scan_session?: string;
|
||||
scan_mode?: string;
|
||||
scan_status?: string;
|
||||
scanned_at?: string;
|
||||
scan_summary?: string;
|
||||
scan_input_count?: number;
|
||||
scan_output_count?: number;
|
||||
scan_filtered_count?: number;
|
||||
elimination_reasons?: Record<string, number>;
|
||||
}
|
||||
|
||||
// ---------- Performance Stats ----------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user