update
This commit is contained in:
parent
1e8c38dfc5
commit
212fda0bbf
Binary file not shown.
Binary file not shown.
@ -1,518 +0,0 @@
|
||||
"""Debug API — 系统日志与运行状态"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.deps import get_current_admin
|
||||
from app.db.database import get_db
|
||||
from app.config import settings, is_trading_hours
|
||||
|
||||
router = APIRouter(prefix="/api/debug", tags=["debug"])
|
||||
|
||||
|
||||
@router.get("/errors")
|
||||
async def get_errors(
|
||||
limit: int = 50,
|
||||
source: str = None,
|
||||
level: str = None,
|
||||
q: str = None,
|
||||
days: int = 7,
|
||||
_admin: dict = Depends(get_current_admin),
|
||||
):
|
||||
"""获取错误日志(管理员)"""
|
||||
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
async with get_db() as db:
|
||||
conditions = ["created_at >= :start"]
|
||||
params = {"start": start}
|
||||
|
||||
if source:
|
||||
conditions.append("source = :source")
|
||||
params["source"] = source
|
||||
if level:
|
||||
conditions.append("level = :level")
|
||||
params["level"] = level
|
||||
if q:
|
||||
conditions.append("(message LIKE :q OR detail LIKE :q OR source LIKE :q)")
|
||||
params["q"] = f"%{q.strip()}%"
|
||||
|
||||
where = " AND ".join(conditions)
|
||||
|
||||
# 总数
|
||||
count_result = await db.execute(
|
||||
text(f"SELECT COUNT(*) FROM error_logs WHERE {where}"), params
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# 查询
|
||||
params["limit"] = limit
|
||||
result = await db.execute(
|
||||
text(
|
||||
f"SELECT id, source, level, message, detail, created_at "
|
||||
f"FROM error_logs WHERE {where} "
|
||||
f"ORDER BY created_at DESC LIMIT :limit"
|
||||
),
|
||||
params,
|
||||
)
|
||||
rows = result.fetchall()
|
||||
errors = []
|
||||
for row in rows:
|
||||
r = row._mapping
|
||||
errors.append({
|
||||
"id": r["id"],
|
||||
"source": r["source"],
|
||||
"level": r["level"],
|
||||
"message": r["message"],
|
||||
"detail": r["detail"] or "",
|
||||
"created_at": str(r["created_at"]) if r["created_at"] else "",
|
||||
})
|
||||
|
||||
# 可选的 source/level 列表(用于前端过滤)
|
||||
sources_result = await db.execute(
|
||||
text("SELECT DISTINCT source FROM error_logs ORDER BY source")
|
||||
)
|
||||
sources = [r[0] for r in sources_result.fetchall()]
|
||||
|
||||
levels_result = await db.execute(
|
||||
text("SELECT DISTINCT level FROM error_logs ORDER BY level")
|
||||
)
|
||||
levels = [r[0] for r in levels_result.fetchall()]
|
||||
|
||||
source_counts_result = await db.execute(
|
||||
text(f"SELECT source, COUNT(*) FROM error_logs WHERE {where} GROUP BY source ORDER BY COUNT(*) DESC"),
|
||||
{key: value for key, value in params.items() if key != "limit"},
|
||||
)
|
||||
source_counts = {r[0]: r[1] for r in source_counts_result.fetchall()}
|
||||
|
||||
level_counts_result = await db.execute(
|
||||
text(f"SELECT level, COUNT(*) FROM error_logs WHERE {where} GROUP BY level ORDER BY COUNT(*) DESC"),
|
||||
{key: value for key, value in params.items() if key != "limit"},
|
||||
)
|
||||
level_counts = {r[0]: r[1] for r in level_counts_result.fetchall()}
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"errors": errors,
|
||||
"sources": sources,
|
||||
"levels": levels,
|
||||
"source_counts": source_counts,
|
||||
"level_counts": level_counts,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/errors")
|
||||
async def clear_errors(
|
||||
days: int = 30,
|
||||
_admin: dict = Depends(get_current_admin),
|
||||
):
|
||||
"""清除旧错误日志(管理员)"""
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
text("DELETE FROM error_logs WHERE created_at < :cutoff"),
|
||||
{"cutoff": cutoff},
|
||||
)
|
||||
deleted = result.rowcount
|
||||
await db.commit()
|
||||
return {"status": "ok", "deleted": deleted}
|
||||
|
||||
|
||||
@router.get("/system")
|
||||
async def system_status(_admin: dict = Depends(get_current_admin)):
|
||||
"""系统运行状态摘要(管理员)"""
|
||||
from app.engine.recommender import _scan_running, _scan_lock
|
||||
|
||||
async with get_db() as db:
|
||||
# 各表数据量
|
||||
tables_counts = {}
|
||||
for t in ["recommendations", "sector_heat", "market_temperature",
|
||||
"recommendation_tracking", "stock_diagnoses",
|
||||
"error_logs", "scan_process_logs", "users"]:
|
||||
result = await db.execute(text(f"SELECT COUNT(*) FROM {t}"))
|
||||
tables_counts[t] = result.scalar() or 0
|
||||
|
||||
# 最近 24h 错误数
|
||||
since = (datetime.now() - timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
result = await db.execute(
|
||||
text("SELECT COUNT(*) FROM error_logs WHERE created_at >= :since"),
|
||||
{"since": since},
|
||||
)
|
||||
recent_errors = result.scalar() or 0
|
||||
|
||||
# 最近错误
|
||||
result = await db.execute(
|
||||
text("SELECT source, message, created_at FROM error_logs ORDER BY created_at DESC LIMIT 5")
|
||||
)
|
||||
last_errors = [
|
||||
{"source": r[0], "message": r[1], "created_at": str(r[2])}
|
||||
for r in result.fetchall()
|
||||
]
|
||||
|
||||
# 数据库文件大小
|
||||
db_path = settings.database_url.replace("sqlite:///", "")
|
||||
db_size_mb = 0
|
||||
if os.path.exists(db_path):
|
||||
db_size_mb = round(os.path.getsize(db_path) / 1024 / 1024, 2)
|
||||
|
||||
return {
|
||||
"is_trading": is_trading_hours(),
|
||||
"scan_running": _scan_running,
|
||||
"scan_locked": _scan_lock.locked(),
|
||||
"recent_errors": recent_errors,
|
||||
"last_errors": last_errors,
|
||||
"tables_counts": tables_counts,
|
||||
"db_size_mb": db_size_mb,
|
||||
}
|
||||
|
||||
|
||||
def _decode_detail(raw: str | None) -> dict:
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
return parsed if isinstance(parsed, dict) else {"value": parsed}
|
||||
except Exception:
|
||||
return {"raw": raw}
|
||||
|
||||
|
||||
def _row_observation(row) -> dict:
|
||||
r = row._mapping
|
||||
return {
|
||||
"id": r["id"],
|
||||
"scan_session": r["scan_session"],
|
||||
"scan_mode": r["scan_mode"] or "",
|
||||
"ts_code": r["ts_code"],
|
||||
"name": r["name"],
|
||||
"theme_name": r["theme_name"] or "",
|
||||
"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 "",
|
||||
"detail": _decode_detail(r["detail_json"]),
|
||||
"created_at": str(r["created_at"]) if r["created_at"] else "",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/scan-logs")
|
||||
async def get_scan_logs(
|
||||
limit: int = 100,
|
||||
scan_session: str = None,
|
||||
days: int = 7,
|
||||
_admin: dict = Depends(get_current_admin),
|
||||
):
|
||||
"""获取筛选过程日志(管理员)"""
|
||||
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
limit = max(1, min(limit, 300))
|
||||
|
||||
async with get_db() as db:
|
||||
selected_session = scan_session
|
||||
if not selected_session:
|
||||
latest = await db.execute(
|
||||
text(
|
||||
"SELECT scan_session FROM scan_process_logs "
|
||||
"WHERE created_at >= :start "
|
||||
"ORDER BY created_at DESC LIMIT 1"
|
||||
),
|
||||
{"start": start},
|
||||
)
|
||||
selected_session = latest.scalar()
|
||||
|
||||
if not selected_session:
|
||||
return {"scan_session": None, "logs": []}
|
||||
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, scan_session, scan_mode, stage, stage_label, status, "
|
||||
"input_count, output_count, filtered_count, summary, detail_json, created_at "
|
||||
"FROM scan_process_logs "
|
||||
"WHERE created_at >= :start AND scan_session = :scan_session "
|
||||
"ORDER BY created_at ASC LIMIT :limit"
|
||||
),
|
||||
{"start": start, "scan_session": selected_session, "limit": limit},
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
logs = []
|
||||
for row in rows:
|
||||
r = row._mapping
|
||||
logs.append({
|
||||
"id": r["id"],
|
||||
"scan_session": r["scan_session"],
|
||||
"scan_mode": r["scan_mode"] or "",
|
||||
"stage": r["stage"],
|
||||
"stage_label": r["stage_label"],
|
||||
"status": r["status"] or "ok",
|
||||
"input_count": r["input_count"] or 0,
|
||||
"output_count": r["output_count"] or 0,
|
||||
"filtered_count": r["filtered_count"] or 0,
|
||||
"summary": r["summary"] or "",
|
||||
"detail": _decode_detail(r["detail_json"]),
|
||||
"created_at": str(r["created_at"]) if r["created_at"] else "",
|
||||
})
|
||||
|
||||
return {
|
||||
"scan_session": selected_session,
|
||||
"logs": logs,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/research-observations")
|
||||
async def get_research_observations(
|
||||
scan_session: str = None,
|
||||
limit: int = 80,
|
||||
days: int = 7,
|
||||
_admin: dict = Depends(get_current_admin),
|
||||
):
|
||||
"""获取候选股投研观察记录(管理员)"""
|
||||
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
limit = max(1, min(limit, 200))
|
||||
|
||||
async with get_db() as db:
|
||||
selected_session = scan_session
|
||||
if not selected_session:
|
||||
latest = await db.execute(
|
||||
text(
|
||||
"SELECT scan_session FROM research_observations "
|
||||
"WHERE created_at >= :start ORDER BY created_at DESC LIMIT 1"
|
||||
),
|
||||
{"start": start},
|
||||
)
|
||||
selected_session = latest.scalar()
|
||||
|
||||
if not selected_session:
|
||||
return {"scan_session": None, "observations": [], "reason_counts": {}}
|
||||
|
||||
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 created_at >= :start AND scan_session = :scan_session "
|
||||
"ORDER BY final_score DESC LIMIT :limit"
|
||||
),
|
||||
{"start": start, "scan_session": selected_session, "limit": limit},
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
observations = [_row_observation(row) for row in rows]
|
||||
reason_counts = {}
|
||||
for item in observations:
|
||||
for part in (item["elimination_reason"] or "未知").split(";"):
|
||||
if not part:
|
||||
continue
|
||||
reason_counts[part] = reason_counts.get(part, 0) + 1
|
||||
|
||||
return {
|
||||
"scan_session": selected_session,
|
||||
"observations": observations,
|
||||
"reason_counts": reason_counts,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/scan-sessions")
|
||||
async def get_scan_sessions(
|
||||
days: int = 7,
|
||||
limit: int = 30,
|
||||
_admin: dict = Depends(get_current_admin),
|
||||
):
|
||||
"""获取筛选会话摘要(管理员)"""
|
||||
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
limit = max(1, min(limit, 100))
|
||||
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT scan_session, scan_mode, stage, status, input_count, "
|
||||
"output_count, filtered_count, summary, created_at "
|
||||
"FROM scan_process_logs "
|
||||
"WHERE created_at >= :start "
|
||||
"ORDER BY created_at DESC LIMIT 1000"
|
||||
),
|
||||
{"start": start},
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
sessions = {}
|
||||
order = []
|
||||
for row in rows:
|
||||
r = row._mapping
|
||||
session_id = r["scan_session"]
|
||||
if session_id not in sessions:
|
||||
sessions[session_id] = {
|
||||
"scan_session": session_id,
|
||||
"scan_mode": r["scan_mode"] or "",
|
||||
"created_at": str(r["created_at"]) if r["created_at"] else "",
|
||||
"stage_count": 0,
|
||||
"status": "ok",
|
||||
"input_count": 0,
|
||||
"final_count": 0,
|
||||
"drop_count": 0,
|
||||
"last_summary": r["summary"] or "",
|
||||
}
|
||||
order.append(session_id)
|
||||
|
||||
item = sessions[session_id]
|
||||
item["stage_count"] += 1
|
||||
item["input_count"] = max(item["input_count"], int(r["input_count"] or 0))
|
||||
item["drop_count"] += int(r["filtered_count"] or 0)
|
||||
if r["stage"] == "final_filter" and item["final_count"] == 0:
|
||||
item["final_count"] = int(r["output_count"] or 0)
|
||||
item["last_summary"] = r["summary"] or item["last_summary"]
|
||||
|
||||
status = (r["status"] or "ok").lower()
|
||||
if status in {"failed", "error", "critical"}:
|
||||
item["status"] = "failed"
|
||||
elif status in {"warning", "empty"} and item["status"] == "ok":
|
||||
item["status"] = status
|
||||
|
||||
return {
|
||||
"sessions": [sessions[sid] for sid in order[:limit]],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/data-source-health")
|
||||
async def get_data_source_health(
|
||||
days: int = 7,
|
||||
_admin: dict = Depends(get_current_admin),
|
||||
):
|
||||
"""数据源健康摘要(管理员,只读)。"""
|
||||
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
known_sources = ["eastmoney", "tencent", "tushare", "akshare", "sina", "news", "tushare_news"]
|
||||
health = {
|
||||
source: {
|
||||
"source": source,
|
||||
"status": "ok",
|
||||
"error_count": 0,
|
||||
"warning_count": 0,
|
||||
"last_error": "",
|
||||
"last_seen_at": "",
|
||||
}
|
||||
for source in known_sources
|
||||
}
|
||||
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT source, level, message, created_at FROM error_logs "
|
||||
"WHERE created_at >= :start "
|
||||
"ORDER BY created_at DESC LIMIT 500"
|
||||
),
|
||||
{"start": start},
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
table_rows = {}
|
||||
for table_name, sql in {
|
||||
"market_temperature": "SELECT trade_date, created_at FROM market_temperature ORDER BY id DESC LIMIT 1",
|
||||
"sector_heat": "SELECT trade_date, created_at FROM sector_heat ORDER BY id DESC LIMIT 1",
|
||||
"recommendations": "SELECT created_at FROM recommendations ORDER BY id DESC LIMIT 1",
|
||||
"news_items": "SELECT created_at FROM news_items ORDER BY id DESC LIMIT 1",
|
||||
"catalysts": "SELECT created_at FROM catalysts ORDER BY id DESC LIMIT 1",
|
||||
}.items():
|
||||
row = (await db.execute(text(sql))).fetchone()
|
||||
table_rows[table_name] = dict(row._mapping) if row else {}
|
||||
|
||||
for row in rows:
|
||||
r = row._mapping
|
||||
source_text = str(r["source"] or "").lower()
|
||||
matched = next((source for source in known_sources if source in source_text), source_text or "unknown")
|
||||
if matched not in health:
|
||||
health[matched] = {
|
||||
"source": matched,
|
||||
"status": "ok",
|
||||
"error_count": 0,
|
||||
"warning_count": 0,
|
||||
"last_error": "",
|
||||
"last_seen_at": "",
|
||||
}
|
||||
item = health[matched]
|
||||
level_text = str(r["level"] or "error").lower()
|
||||
if level_text in {"warning", "warn"}:
|
||||
item["warning_count"] += 1
|
||||
if item["status"] == "ok":
|
||||
item["status"] = "warning"
|
||||
else:
|
||||
item["error_count"] += 1
|
||||
item["status"] = "error"
|
||||
if not item["last_error"]:
|
||||
item["last_error"] = r["message"] or ""
|
||||
item["last_seen_at"] = str(r["created_at"] or "")
|
||||
|
||||
return {
|
||||
"days": days,
|
||||
"sources": sorted(health.values(), key=lambda item: (item["status"] != "error", item["status"] != "warning", item["source"])),
|
||||
"freshness": {
|
||||
key: {k: str(v or "") for k, v in value.items()}
|
||||
for key, value in table_rows.items()
|
||||
},
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
async def get_tasks(_admin: dict = Depends(get_current_admin)):
|
||||
"""后台任务中心摘要(管理员,只读)。"""
|
||||
from app.engine.scheduler import scheduler
|
||||
from app.engine.recommender import _scan_running, _scan_lock
|
||||
|
||||
jobs = []
|
||||
for job in scheduler.get_jobs():
|
||||
jobs.append({
|
||||
"id": job.id,
|
||||
"name": job.name,
|
||||
"next_run_time": str(job.next_run_time) if job.next_run_time else "",
|
||||
"trigger": str(job.trigger),
|
||||
})
|
||||
|
||||
async with get_db() as db:
|
||||
recent_scan = await db.execute(
|
||||
text(
|
||||
"SELECT scan_session, scan_mode, stage, status, output_count, summary, created_at "
|
||||
"FROM scan_process_logs ORDER BY created_at DESC LIMIT 12"
|
||||
)
|
||||
)
|
||||
recent_errors = await db.execute(
|
||||
text(
|
||||
"SELECT source, level, message, created_at FROM error_logs "
|
||||
"ORDER BY created_at DESC LIMIT 8"
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"scheduler_running": scheduler.running,
|
||||
"scan_running": _scan_running,
|
||||
"scan_locked": _scan_lock.locked(),
|
||||
"job_count": len(jobs),
|
||||
"jobs": sorted(jobs, key=lambda item: item["next_run_time"] or "9999"),
|
||||
"recent_scan_logs": [
|
||||
{
|
||||
"scan_session": r._mapping["scan_session"],
|
||||
"scan_mode": r._mapping["scan_mode"] or "",
|
||||
"stage": r._mapping["stage"],
|
||||
"status": r._mapping["status"] or "ok",
|
||||
"output_count": r._mapping["output_count"] or 0,
|
||||
"summary": r._mapping["summary"] or "",
|
||||
"created_at": str(r._mapping["created_at"] or ""),
|
||||
}
|
||||
for r in recent_scan.fetchall()
|
||||
],
|
||||
"recent_errors": [
|
||||
{
|
||||
"source": r._mapping["source"],
|
||||
"level": r._mapping["level"],
|
||||
"message": r._mapping["message"],
|
||||
"created_at": str(r._mapping["created_at"] or ""),
|
||||
}
|
||||
for r in recent_errors.fetchall()
|
||||
],
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
}
|
||||
@ -2,12 +2,10 @@
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.data.cache import cache
|
||||
from app.engine.recommender import get_latest_recommendations
|
||||
from app.config import settings, is_trading_hours, should_prefer_realtime_today, today_trade_date
|
||||
from app.core.deps import get_current_admin
|
||||
from app.config import is_trading_hours, should_prefer_realtime_today, today_trade_date
|
||||
|
||||
router = APIRouter(prefix="/api/market", tags=["market"])
|
||||
|
||||
@ -48,158 +46,5 @@ async def get_temperature():
|
||||
|
||||
@router.get("/overview")
|
||||
async def get_overview():
|
||||
"""市场概况快照。
|
||||
|
||||
页面访问不拉腾讯/Tushare。当前库里还没有指数快照表,先返回空数组。
|
||||
后续应由扫描任务把指数概览写入本地表后再展示。
|
||||
"""
|
||||
"""市场概况快照。"""
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/strategy-board")
|
||||
async def get_strategy_board():
|
||||
"""获取今日市场作战面板(只读,不触发 LLM)"""
|
||||
cache_key = "market:strategy_board:rules"
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
from app.llm.strategy_board import build_strategy_board
|
||||
result = await build_strategy_board(include_llm=False)
|
||||
cache.set(cache_key, result, settings.cache_ttl_realtime)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/strategy-iteration")
|
||||
async def get_strategy_iteration(limit: int = 50):
|
||||
"""获取策略复盘迭代建议(只读,不触发 LLM)"""
|
||||
cache_key = f"market:strategy_iteration:{limit}:rules"
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
from app.llm.strategy_iteration import build_strategy_iteration_report
|
||||
result = await build_strategy_iteration_report(limit=limit, include_llm=False)
|
||||
cache.set(cache_key, result, settings.cache_ttl_realtime)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/strategy-configs")
|
||||
async def get_strategy_configs(_admin: dict = Depends(get_current_admin)):
|
||||
"""获取当前策略配置中心状态。"""
|
||||
from app.llm.strategy_config import (
|
||||
get_active_prompt_configs,
|
||||
get_active_strategy_configs,
|
||||
get_recent_config_changes,
|
||||
)
|
||||
|
||||
return {
|
||||
"strategies": await get_active_strategy_configs(),
|
||||
"prompts": await get_active_prompt_configs(),
|
||||
"changes": await get_recent_config_changes(limit=30),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/strategy-configs/{strategy_id}/rollback")
|
||||
async def rollback_strategy_config(strategy_id: str, _admin: dict = Depends(get_current_admin)):
|
||||
"""回滚某个策略到上一配置版本。"""
|
||||
from app.llm.strategy_config import rollback_strategy_config as rollback
|
||||
|
||||
try:
|
||||
result = await rollback(strategy_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
cache.delete("market:strategy_board:rules")
|
||||
cache.delete("market:strategy_iteration:80:rules")
|
||||
cache.delete("market:strategy_iteration:50:rules")
|
||||
return {"status": "ok", "strategy": result}
|
||||
|
||||
|
||||
@router.get("/ops-status")
|
||||
async def get_ops_status():
|
||||
"""管理员任务中心状态与数据新鲜度(只读,不触发扫描或 LLM)。"""
|
||||
from sqlalchemy import text
|
||||
from app.db.database import get_db
|
||||
from app.engine.recommender import _scan_running
|
||||
|
||||
async with get_db() as db:
|
||||
rec_row = (await db.execute(
|
||||
text(
|
||||
"SELECT created_at FROM recommendations "
|
||||
"ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
)).fetchone()
|
||||
tracking_row = (await db.execute(
|
||||
text(
|
||||
"SELECT track_date, created_at FROM recommendation_tracking "
|
||||
"ORDER BY track_date DESC, id DESC LIMIT 1"
|
||||
)
|
||||
)).fetchone()
|
||||
market_row = (await db.execute(
|
||||
text(
|
||||
"SELECT trade_date, created_at FROM market_temperature "
|
||||
"ORDER BY REPLACE(trade_date, '-', '') DESC, id DESC LIMIT 1"
|
||||
)
|
||||
)).fetchone()
|
||||
sector_row = (await db.execute(
|
||||
text(
|
||||
"SELECT trade_date, created_at FROM sector_heat "
|
||||
"ORDER BY REPLACE(trade_date, '-', '') DESC, id DESC LIMIT 1"
|
||||
)
|
||||
)).fetchone()
|
||||
def _fmt_dt(value):
|
||||
return str(value or "")
|
||||
|
||||
latest_market_date = str(market_row._mapping["trade_date"]) if market_row else ""
|
||||
latest_sector_date = str(sector_row._mapping["trade_date"]) if sector_row else ""
|
||||
latest_tracking_date = str(tracking_row._mapping["track_date"]) if tracking_row else ""
|
||||
today = today_trade_date()
|
||||
sector_lagging = bool(latest_sector_date and latest_sector_date.replace("-", "") < today)
|
||||
market_lagging = bool(latest_market_date and latest_market_date.replace("-", "") < today)
|
||||
|
||||
return {
|
||||
"scan_running": _scan_running,
|
||||
"scan_mode": "realtime_today" if should_prefer_realtime_today(latest_market_date) else "post_market",
|
||||
"is_trading": is_trading_hours(),
|
||||
"data_freshness": {
|
||||
"market_trade_date": latest_market_date,
|
||||
"sector_trade_date": latest_sector_date,
|
||||
"tracking_trade_date": latest_tracking_date,
|
||||
"last_recommendation_created_at": _fmt_dt(rec_row._mapping["created_at"]) if rec_row else "",
|
||||
"last_tracking_created_at": _fmt_dt(tracking_row._mapping["created_at"]) if tracking_row else "",
|
||||
"last_market_created_at": _fmt_dt(market_row._mapping["created_at"]) if market_row else "",
|
||||
"last_sector_created_at": _fmt_dt(sector_row._mapping["created_at"]) if sector_row else "",
|
||||
"status": "stale" if sector_lagging or market_lagging else "fresh" if latest_market_date else "empty",
|
||||
"message": (
|
||||
f"板块快照仍停留在 {latest_sector_date},展示层将优先使用今日实时板块榜。"
|
||||
if sector_lagging else
|
||||
f"最新市场日期 {latest_market_date},最近跟踪 {latest_tracking_date or '暂无'}"
|
||||
if latest_market_date else
|
||||
"暂无市场缓存数据,请由管理员触发扫描。"
|
||||
),
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
},
|
||||
"actions": [
|
||||
{"key": "refresh", "label": "立即扫描", "admin_only": True},
|
||||
{"key": "update_tracking", "label": "更新跟踪", "admin_only": True},
|
||||
{"key": "generate_strategy_board", "label": "生成策略板", "admin_only": True},
|
||||
{"key": "generate_strategy_iteration", "label": "生成策略复盘", "admin_only": True},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/generate-strategy-board")
|
||||
async def generate_strategy_board(_admin: dict = Depends(get_current_admin)):
|
||||
"""管理员手动生成带 LLM 说明的策略看板"""
|
||||
from app.llm.strategy_board import build_strategy_board
|
||||
result = await build_strategy_board(include_llm=True)
|
||||
cache.delete("market:strategy_board:rules")
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/generate-strategy-iteration")
|
||||
async def generate_strategy_iteration(limit: int = 50, _admin: dict = Depends(get_current_admin)):
|
||||
"""管理员手动生成带 LLM 分析的策略复盘"""
|
||||
from app.llm.strategy_iteration import build_strategy_iteration_report
|
||||
result = await build_strategy_iteration_report(limit=limit, include_llm=True, apply_auto_config=True)
|
||||
cache.delete(f"market:strategy_iteration:{limit}:rules")
|
||||
cache.delete("market:strategy_board:rules")
|
||||
return result
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -8,6 +8,7 @@ import traceback
|
||||
from datetime import datetime
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
from app.engine.recommender import refresh_recommendations
|
||||
from app.engine.watchlist import analyze_watchlist_for_all_users
|
||||
@ -26,7 +27,6 @@ async def _run_scan(session_name: str):
|
||||
rec_count = len(result.get("recommendations", []))
|
||||
logger.info(f"扫描完成: {rec_count} 只推荐股票")
|
||||
|
||||
# 通过 WebSocket 推送更新
|
||||
await broadcast_update({
|
||||
"type": "scan_update",
|
||||
"session": session_name,
|
||||
@ -71,27 +71,13 @@ async def _run_watchlist_analysis():
|
||||
await log_error("scheduler", f"自选股定时分析失败: {e}", detail=traceback.format_exc())
|
||||
|
||||
|
||||
async def _run_strategy_iteration():
|
||||
"""收盘后生成策略复盘,并允许小幅自动配置调整。"""
|
||||
logger.info("=== 开始策略复盘与配置校准 ===")
|
||||
async def _run_trigger_monitor():
|
||||
"""盘中买点触发监控 — 每分钟检查埋伏池是否命中条件。"""
|
||||
try:
|
||||
from app.llm.strategy_iteration import build_strategy_iteration_report
|
||||
report = await build_strategy_iteration_report(limit=80, include_llm=False, apply_auto_config=True)
|
||||
logger.info(
|
||||
"策略复盘完成: sample=%s auto_change=%s",
|
||||
report.get("sample_size", 0),
|
||||
bool(report.get("auto_config_change")),
|
||||
)
|
||||
await broadcast_update({
|
||||
"type": "strategy_iteration_ready",
|
||||
"sample_size": report.get("sample_size", 0),
|
||||
"auto_config_changed": bool(report.get("auto_config_change")),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
})
|
||||
from app.engine.trigger_monitor import check_triggers
|
||||
await check_triggers()
|
||||
except Exception as e:
|
||||
logger.error(f"策略复盘自动校准失败: {e}")
|
||||
from app.db.error_logger import log_error
|
||||
await log_error("scheduler", f"策略复盘自动校准失败: {e}", detail=traceback.format_exc())
|
||||
logger.debug(f"买点触发监控异常: {e}")
|
||||
|
||||
|
||||
def setup_scheduler():
|
||||
@ -113,13 +99,13 @@ def setup_scheduler():
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# 盘前准备 09:00 - 计算前一日市场温度和板块数据
|
||||
# 盘前埋伏扫描 09:00
|
||||
scheduler.add_job(
|
||||
_run_scan, CronTrigger(hour=9, minute=0, day_of_week="mon-fri"),
|
||||
args=["pre_market"], id="pre_market", replace_existing=True
|
||||
args=["pre_market_ambush"], id="pre_market_ambush", replace_existing=True
|
||||
)
|
||||
|
||||
# 盘中扫描:按交易节奏执行,避免高频重复计算
|
||||
# 盘中扫描
|
||||
scan_schedule = [
|
||||
("morning_open_0935", 9, 35, "morning_open"),
|
||||
("morning_open_0950", 9, 50, "morning_open"),
|
||||
@ -141,7 +127,7 @@ def setup_scheduler():
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# 收盘总结 16:00(Tushare 日线数据通常在 15:30 后更新完成)
|
||||
# 收盘总结 16:00
|
||||
scheduler.add_job(
|
||||
_run_scan, CronTrigger(hour=16, minute=0, day_of_week="mon-fri"),
|
||||
args=["post_market"], id="post_market", replace_existing=True
|
||||
@ -152,9 +138,12 @@ def setup_scheduler():
|
||||
id="watchlist_analysis", replace_existing=True
|
||||
)
|
||||
|
||||
# 盘中买点触发监控:9:35-15:00 每分钟执行
|
||||
scheduler.add_job(
|
||||
_run_strategy_iteration, CronTrigger(hour=16, minute=35, day_of_week="mon-fri"),
|
||||
id="strategy_iteration", replace_existing=True
|
||||
_run_trigger_monitor,
|
||||
IntervalTrigger(minutes=1, start_date="2020-01-01 09:35:00", end_date="2020-01-01 15:00:00"),
|
||||
id="trigger_monitor",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
logger.info("盘中调度器已配置完成")
|
||||
|
||||
@ -32,7 +32,6 @@ from app.analysis.signals import generate_signals
|
||||
from app.analysis.intraday import (
|
||||
intraday_active_market_recall,
|
||||
intraday_market_temperature,
|
||||
intraday_filter_stocks,
|
||||
intraday_sector_scan,
|
||||
)
|
||||
from app.data.models import MarketTemperature, SectorInfo, TechnicalSignal, Recommendation
|
||||
@ -156,7 +155,7 @@ async def run_screening(trade_date: str = None, scan_session: str = "manual") ->
|
||||
merge_sectors_to_themes(await intraday_sector_scan(hot_sectors), limit=settings.top_sector_count)
|
||||
)
|
||||
|
||||
strategy_profile = await select_strategy_profile(market_temp, hot_sectors, intraday)
|
||||
strategy_profile = await select_strategy_profile(market_temp, hot_sectors, intraday, scan_session=scan_session)
|
||||
logger.info(
|
||||
f"=== 今日策略: {strategy_profile.name} ({strategy_profile.strategy_id}) "
|
||||
f"threshold={strategy_profile.buy_threshold} min_score={strategy_profile.min_score} ==="
|
||||
@ -244,6 +243,27 @@ async def run_screening(trade_date: str = None, scan_session: str = "manual") ->
|
||||
detail={"requested": quote_requested, "updated": quote_updated, "error": quote_error},
|
||||
)
|
||||
|
||||
# ── 盘中注入实时资金流(东方财富 f62 主力净流入) ──
|
||||
if intraday and candidates:
|
||||
flow_updated = 0
|
||||
try:
|
||||
from app.data.eastmoney_client import get_a_share_realtime_ranking
|
||||
all_quotes = await get_a_share_realtime_ranking(sort_by="f62", descending=True, page_size=3000)
|
||||
if all_quotes:
|
||||
flow_map = {
|
||||
item.get("ts_code", ""): float(item.get("main_net_inflow", 0) or 0) / 10000
|
||||
for item in all_quotes if item.get("ts_code")
|
||||
}
|
||||
for c in candidates:
|
||||
ts_code = c.get("ts_code", "")
|
||||
if ts_code in flow_map:
|
||||
c["main_net_inflow"] = flow_map[ts_code]
|
||||
c["inflow_ratio"] = 0 # 实时无法算占比,置 0
|
||||
flow_updated += 1
|
||||
logger.info(f"盘中实时资金流注入: {flow_updated}/{len(candidates)} 只")
|
||||
except Exception as e:
|
||||
logger.warning(f"盘中实时资金流注入失败,使用原始数据: {e}")
|
||||
|
||||
# ── Step 3: 规则评分与交易计划 ──
|
||||
logger.info("=== Step 3: 规则评分与交易计划 ===")
|
||||
scoring_metrics: dict = {}
|
||||
@ -670,21 +690,13 @@ async def _build_candidate_pool(
|
||||
|
||||
if intraday:
|
||||
try:
|
||||
intraday_candidates = await intraday_filter_stocks(hot_sectors)
|
||||
intraday_candidates = await intraday_active_market_recall(limit=settings.candidate_pool_limit)
|
||||
except Exception as e:
|
||||
logger.warning(f"盘中异动召回失败: {e}")
|
||||
logger.warning(f"盘中活跃股召回失败: {e}")
|
||||
intraday_candidates = []
|
||||
_merge_candidate_batch(merged, intraday_candidates, route="intraday_active")
|
||||
|
||||
try:
|
||||
realtime_candidates = await intraday_active_market_recall(limit=settings.candidate_pool_limit)
|
||||
except Exception as e:
|
||||
logger.warning(f"实时全市场召回失败: {e}")
|
||||
realtime_candidates = []
|
||||
_merge_candidate_batch(merged, realtime_candidates, route="realtime_market")
|
||||
else:
|
||||
intraday_candidates = []
|
||||
realtime_candidates = []
|
||||
|
||||
candidates = list(merged.values())
|
||||
candidates.sort(key=lambda item: (
|
||||
@ -698,7 +710,7 @@ async def _build_candidate_pool(
|
||||
logger.info(
|
||||
f"Step 2 多路召回完成: sector={len(sector_candidates)} "
|
||||
f"trend={len(trend_candidates)} "
|
||||
f"{'intraday=' + str(len(intraday_candidates)) + ' realtime=' + str(len(realtime_candidates)) if intraday else ''} "
|
||||
f"{'intraday=' + str(len(intraday_candidates)) if intraday else ''} "
|
||||
f"→ merged={len(top)}"
|
||||
)
|
||||
if metrics is not None:
|
||||
@ -706,7 +718,6 @@ async def _build_candidate_pool(
|
||||
"sector_recall": len(sector_candidates),
|
||||
"trend_scan": len(trend_candidates),
|
||||
"intraday_active": len(intraday_candidates),
|
||||
"realtime_market": len(realtime_candidates),
|
||||
}
|
||||
metrics.update({
|
||||
"route_counts": route_counts,
|
||||
@ -949,7 +960,6 @@ async def _build_recommendations(
|
||||
EntrySignal,
|
||||
)
|
||||
from app.analysis.signals import generate_signals
|
||||
from app.analysis.capital_flow import _score_valuation
|
||||
|
||||
# 名称和行业映射
|
||||
stock_basic = tushare_client.get_stock_basic()
|
||||
@ -1017,7 +1027,7 @@ async def _build_recommendations(
|
||||
supply_demand_score = score_supply_demand(df)
|
||||
price_action_score = _score_price_action(df, entry_signal)
|
||||
trend_score = _score_trend(df)
|
||||
capital_score = _score_capital_simple(stock)
|
||||
capital_score = 0 # 不再单独算 capital_simple,统一走 stock_money
|
||||
flow_momentum_score = _score_flow_momentum(stock, sector, hot_sectors)
|
||||
sector_stage = _get_sector_stage(sector, hot_sectors)
|
||||
hot_theme_match = find_hot_theme_match(sector, hot_sectors)
|
||||
@ -1116,12 +1126,7 @@ async def _build_recommendations(
|
||||
final_score *= 1.03
|
||||
boosts.append({"label": "新闻催化加权", "value": "+3%", "reason": catalyst_reasons[0] if catalyst_reasons else f"催化分{catalyst_score:.0f}"})
|
||||
|
||||
flow_multiplier = _flow_confirmation_multiplier(stock, hot_theme_match, market_temp)
|
||||
final_score *= flow_multiplier
|
||||
if flow_multiplier > 1:
|
||||
boosts.append({"label": "资金主线共振", "value": f"+{round((flow_multiplier - 1) * 100)}%", "reason": "资金、量能与主线同向"})
|
||||
elif flow_multiplier < 1:
|
||||
boosts.append({"label": "资金确认不足", "value": f"-{round((1 - flow_multiplier) * 100)}%", "reason": "资金或主线承接不足"})
|
||||
flow_multiplier = 1.0
|
||||
|
||||
theme_penalty = 1.0
|
||||
if not hot_theme_match:
|
||||
@ -1132,28 +1137,10 @@ async def _build_recommendations(
|
||||
final_score *= theme_penalty
|
||||
|
||||
signal_matches_profile = bool(signal_priority and signal_name in signal_priority[:4])
|
||||
profile_multiplier = 1.0
|
||||
if signal_type != EntrySignal.NONE and signal_priority:
|
||||
priority_rank = signal_priority.index(signal_type.value)
|
||||
if priority_rank == 0:
|
||||
profile_multiplier = 1.08
|
||||
final_score *= profile_multiplier
|
||||
elif priority_rank == 1:
|
||||
profile_multiplier = 1.04
|
||||
final_score *= profile_multiplier
|
||||
elif priority_rank >= 3:
|
||||
profile_multiplier = 0.94
|
||||
final_score *= profile_multiplier
|
||||
if profile_multiplier != 1.0:
|
||||
boosts.append({
|
||||
"label": "策略匹配度",
|
||||
"value": f"{'+' if profile_multiplier > 1 else '-'}{round(abs(profile_multiplier - 1) * 100)}%",
|
||||
"reason": f"{signal_name} 与今日策略优先级匹配",
|
||||
})
|
||||
|
||||
pe = stock.get("pe")
|
||||
pb = stock.get("pb")
|
||||
valuation_score = _score_valuation(pe, pb)
|
||||
valuation_score = 50 # 不再计算估值分,短线无意义
|
||||
|
||||
level = _score_to_level(final_score)
|
||||
signal = "HOLD"
|
||||
|
||||
168
backend/app/engine/trigger_monitor.py
Normal file
168
backend/app/engine/trigger_monitor.py
Normal file
@ -0,0 +1,168 @@
|
||||
"""盘中买点触发监控
|
||||
|
||||
从当日埋伏池加载标的,每分钟用腾讯批量行情检查是否命中买入条件。
|
||||
命中后通过飞书 webhook 推送告警,每只票每天最多触发一次。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.data.cache import cache
|
||||
from app.config import is_trading_hours
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def check_triggers():
|
||||
"""主入口:检查当日埋伏池是否有买点触发。"""
|
||||
if not is_trading_hours():
|
||||
return
|
||||
|
||||
pool = await _load_today_ambush_pool()
|
||||
if not pool:
|
||||
return
|
||||
|
||||
from app.data.tencent_client import get_realtime_quotes_batch
|
||||
|
||||
codes = [item["ts_code"] for item in pool]
|
||||
quotes = await get_realtime_quotes_batch(codes)
|
||||
|
||||
for item in pool:
|
||||
ts_code = item["ts_code"]
|
||||
quote = quotes.get(ts_code)
|
||||
if not quote or quote.price <= 0:
|
||||
continue
|
||||
|
||||
# 去重:每只票每天最多触发一次
|
||||
dedup_key = f"trigger_fired:{ts_code}:{datetime.now().strftime('%Y%m%d')}"
|
||||
if cache.get(dedup_key):
|
||||
continue
|
||||
|
||||
trigger_type = _check_trigger_condition(item, quote)
|
||||
if trigger_type:
|
||||
cache.set(dedup_key, True, 86400)
|
||||
await _send_trigger_alert(item, quote, trigger_type)
|
||||
|
||||
|
||||
def _check_trigger_condition(item: dict, quote) -> str | None:
|
||||
"""检查是否命中买入条件。返回触发类型或 None。"""
|
||||
entry_price = item.get("entry_price")
|
||||
entry_signal_type = item.get("entry_signal_type", "")
|
||||
volume_ratio = quote.volume_ratio or 0
|
||||
|
||||
if not entry_price or entry_price <= 0:
|
||||
return None
|
||||
|
||||
price = quote.price
|
||||
|
||||
# 突破型:价格站上 entry_price 且量比 > 1.5
|
||||
if entry_signal_type in ("breakout", "breakout_confirm"):
|
||||
if price >= entry_price and volume_ratio >= 1.5:
|
||||
return "突破确认"
|
||||
|
||||
# 回踩型:价格回到 entry_price 附近(±2%) 且缩量
|
||||
elif entry_signal_type == "pullback":
|
||||
if abs(price - entry_price) / entry_price <= 0.02 and volume_ratio < 0.8:
|
||||
return "回踩到位"
|
||||
|
||||
# 启动型:价格突破 entry_price 且量比 > 1.2
|
||||
elif entry_signal_type in ("launch", "reversal"):
|
||||
if price >= entry_price and volume_ratio >= 1.2:
|
||||
return "启动放量"
|
||||
|
||||
# 通用:价格站上 entry_price 且量比 > 1.5
|
||||
else:
|
||||
if price >= entry_price and volume_ratio >= 1.5:
|
||||
return "放量突破"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _load_today_ambush_pool() -> list[dict]:
|
||||
"""从数据库加载当日可操作/重点关注的埋伏标的。"""
|
||||
cache_key = "trigger_pool:today"
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
from app.db.database import get_db
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT ts_code, name, entry_price, stop_loss, target_price, "
|
||||
"entry_signal_type, action_plan, sector, score "
|
||||
"FROM recommendations "
|
||||
"WHERE date(created_at) = :today "
|
||||
"AND action_plan IN ('可操作', '重点关注') "
|
||||
"ORDER BY score DESC LIMIT 10"
|
||||
),
|
||||
{"today": today},
|
||||
)
|
||||
rows = result.fetchall()
|
||||
pool = [
|
||||
{
|
||||
"ts_code": r._mapping["ts_code"],
|
||||
"name": r._mapping["name"],
|
||||
"entry_price": r._mapping["entry_price"],
|
||||
"stop_loss": r._mapping["stop_loss"],
|
||||
"target_price": r._mapping["target_price"],
|
||||
"entry_signal_type": r._mapping["entry_signal_type"] or "",
|
||||
"action_plan": r._mapping["action_plan"],
|
||||
"sector": r._mapping["sector"] or "",
|
||||
"score": r._mapping["score"] or 0,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
# 缓存 5 分钟,避免每分钟查 DB
|
||||
cache.set(cache_key, pool, 300)
|
||||
return pool
|
||||
except Exception as e:
|
||||
logger.warning(f"加载埋伏池失败: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def _send_trigger_alert(item: dict, quote, trigger_type: str):
|
||||
"""通过飞书发送买点触发告警。"""
|
||||
from app.notifications.feishu import send_feishu_alert
|
||||
from app.config import settings
|
||||
|
||||
if not settings.recommendation_push_enabled:
|
||||
return
|
||||
|
||||
name = item["name"]
|
||||
ts_code = item["ts_code"]
|
||||
sector = item.get("sector", "")
|
||||
entry_price = item.get("entry_price", 0)
|
||||
target_price = item.get("target_price")
|
||||
stop_loss = item.get("stop_loss")
|
||||
score = item.get("score", 0)
|
||||
|
||||
price_str = f"{quote.price:.2f}"
|
||||
pct_str = f"{quote.pct_chg:+.1f}%"
|
||||
vr_str = f"{quote.volume_ratio:.1f}" if quote.volume_ratio else "-"
|
||||
|
||||
lines = [
|
||||
f"🎯 买点触发: {name} ({ts_code})",
|
||||
f"触发类型: {trigger_type}",
|
||||
f"当前价: {price_str} ({pct_str})",
|
||||
f"量比: {vr_str}",
|
||||
f"板块: {sector}",
|
||||
f"评分: {score:.0f}",
|
||||
f"入场价: {entry_price:.2f}" if entry_price else "",
|
||||
f"目标价: {target_price:.2f}" if target_price else "",
|
||||
f"止损价: {stop_loss:.2f}" if stop_loss else "",
|
||||
f"建议: {item.get('action_plan', '观察')}",
|
||||
]
|
||||
message = "\n".join(line for line in lines if line)
|
||||
|
||||
await send_feishu_alert(
|
||||
source="trigger_monitor",
|
||||
message=f"买点触发: {name} {trigger_type}",
|
||||
detail=message,
|
||||
level="info",
|
||||
)
|
||||
logger.info(f"买点触发告警已发送: {name} {trigger_type} @ {quote.price}")
|
||||
Binary file not shown.
@ -1,351 +0,0 @@
|
||||
"""市场作战面板
|
||||
|
||||
把市场温度、板块、推荐和历史跟踪结果汇总成每天可执行的策略视图。
|
||||
规则层保证稳定输出,LLM 层负责补充解释和迭代建议。
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from app.config import settings, should_prefer_realtime_today, today_trade_date
|
||||
from app.data.models import (
|
||||
MarketTemperature,
|
||||
Recommendation,
|
||||
SectorInfo,
|
||||
StrategyBoard,
|
||||
StrategyFocus,
|
||||
StrategySectorFocus,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _sector_pct(sector: SectorInfo) -> float:
|
||||
return float(sector.realtime_pct_change if sector.realtime_pct_change is not None else sector.pct_change)
|
||||
|
||||
|
||||
def _sector_turnover(sector: SectorInfo) -> float:
|
||||
return float(sector.realtime_turnover_rate if sector.realtime_turnover_rate is not None else sector.turnover_avg)
|
||||
|
||||
|
||||
def _sector_up_count(sector: SectorInfo) -> int:
|
||||
return int(sector.realtime_up_count if sector.realtime_up_count is not None else 0)
|
||||
|
||||
|
||||
def _sector_down_count(sector: SectorInfo) -> int:
|
||||
return int(sector.realtime_down_count if sector.realtime_down_count is not None else 0)
|
||||
|
||||
|
||||
def _sector_breadth(sector: SectorInfo) -> int:
|
||||
return _sector_up_count(sector) - _sector_down_count(sector)
|
||||
|
||||
|
||||
def _sector_strength_score(sector: SectorInfo) -> float:
|
||||
strength = _sector_pct(sector) * 12
|
||||
strength += min(max(_sector_breadth(sector), -30), 30) * 0.6
|
||||
strength += min(_sector_turnover(sector), 12) * 1.5
|
||||
strength += min(sector.limit_up_count, 8) * 2.5
|
||||
strength += min(sector.days_continuous, 5) * 1.5
|
||||
return round(strength, 1)
|
||||
|
||||
|
||||
async def build_strategy_board(include_llm: bool = False) -> dict:
|
||||
"""生成今日市场作战面板。"""
|
||||
from app.engine.recommender import (
|
||||
get_latest_recommendations,
|
||||
get_latest_sectors,
|
||||
get_performance_stats,
|
||||
)
|
||||
|
||||
latest = await get_latest_recommendations()
|
||||
market_temp = latest.get("market_temp")
|
||||
recommendations = latest.get("recommendations", [])
|
||||
sectors = await get_latest_sectors()
|
||||
performance = await get_performance_stats()
|
||||
from app.llm.strategy_iteration import build_strategy_iteration_report
|
||||
iteration_report = await build_strategy_iteration_report(limit=50, include_llm=include_llm)
|
||||
|
||||
board = _build_rule_board(market_temp, sectors, recommendations, performance)
|
||||
board.iteration_report = iteration_report
|
||||
if iteration_report.get("adjustment_suggestions"):
|
||||
board.iteration_notes = [
|
||||
s.get("reason", "")
|
||||
for s in iteration_report["adjustment_suggestions"][:3]
|
||||
if s.get("reason")
|
||||
] or board.iteration_notes
|
||||
|
||||
if include_llm and settings.deepseek_api_key:
|
||||
board.ai_review = await _generate_ai_review(board, recommendations, performance)
|
||||
if board.ai_review:
|
||||
board.generated_by = "rules+llm"
|
||||
|
||||
return board.model_dump()
|
||||
|
||||
|
||||
def _build_rule_board(
|
||||
market_temp: MarketTemperature | None,
|
||||
sectors: list[SectorInfo],
|
||||
recommendations: list[Recommendation],
|
||||
performance: dict,
|
||||
) -> StrategyBoard:
|
||||
temp = market_temp.temperature if market_temp else 0
|
||||
raw_trade_date = market_temp.trade_date if market_temp else ""
|
||||
prefer_realtime = should_prefer_realtime_today(raw_trade_date)
|
||||
trade_date = today_trade_date() if prefer_realtime else raw_trade_date
|
||||
data_mode = "realtime_today" if prefer_realtime else "daily_snapshot"
|
||||
market_regime, risk_level, action_bias, position_suggestion = _classify_market(temp, market_temp)
|
||||
|
||||
actionable = [r for r in recommendations if r.action_plan == "可操作"]
|
||||
watch = [r for r in recommendations if r.action_plan == "重点关注"]
|
||||
strong_sectors = [
|
||||
s for s in sectors[:5]
|
||||
if _sector_pct(s) >= 1.5 and (_sector_breadth(s) > 0 or s.limit_up_count >= 2)
|
||||
]
|
||||
avg_score = (
|
||||
round(sum(r.score for r in recommendations) / len(recommendations), 1)
|
||||
if recommendations else 0
|
||||
)
|
||||
|
||||
recommended_mode = _choose_strategy_mode(temp, sectors, recommendations)
|
||||
strategy_focus = _build_strategy_focus(temp, sectors, recommendations)
|
||||
watch_sectors = [_sector_focus(s) for s in sectors[:5]]
|
||||
avoid_rules = _build_avoid_rules(temp, sectors, recommendations)
|
||||
iteration_notes = _build_iteration_notes(performance, recommendations)
|
||||
mode_prefix = "今日实时视角:" if prefer_realtime else ""
|
||||
|
||||
summary = (
|
||||
f"{mode_prefix}{market_regime},风险等级{risk_level}。"
|
||||
f"当前 {len(recommendations)} 只入选,其中 {len(actionable)} 只可操作、"
|
||||
f"{len(watch)} 只重点关注,平均分 {avg_score}。"
|
||||
f"{'主线活跃板块 ' + ' / '.join(s.sector_name for s in strong_sectors[:3]) + '。' if strong_sectors else '板块尚未形成强共振,优先等确认。'}"
|
||||
)
|
||||
|
||||
metrics = {
|
||||
"temperature": temp,
|
||||
"recommendation_count": len(recommendations),
|
||||
"actionable_count": len(actionable),
|
||||
"watch_count": len(watch),
|
||||
"avg_score": avg_score,
|
||||
"win_rate": performance.get("win_rate", 0),
|
||||
"avg_return": performance.get("avg_return", 0),
|
||||
"tracked": performance.get("tracked", 0),
|
||||
}
|
||||
|
||||
return StrategyBoard(
|
||||
trade_date=trade_date,
|
||||
data_mode=data_mode,
|
||||
market_regime=market_regime,
|
||||
risk_level=risk_level,
|
||||
action_bias=action_bias,
|
||||
position_suggestion=position_suggestion,
|
||||
summary=summary,
|
||||
recommended_mode=recommended_mode,
|
||||
strategy_focus=strategy_focus,
|
||||
watch_sectors=watch_sectors,
|
||||
avoid_rules=avoid_rules,
|
||||
iteration_notes=iteration_notes,
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
|
||||
def _classify_market(
|
||||
temp: float, market_temp: MarketTemperature | None
|
||||
) -> tuple[str, str, str, str]:
|
||||
if temp >= 75:
|
||||
return ("强势进攻", "低", "可积极关注主线龙头和突破确认", "单票 20%-30%,总仓 50%-70%")
|
||||
if temp >= 60:
|
||||
return ("修复偏强", "中低", "优先做早中期板块的突破/回踩确认", "单票 15%-25%,总仓 40%-60%")
|
||||
if temp >= 45:
|
||||
return ("震荡分化", "中", "只做板块一致性强的低吸或确认机会", "单票 10%-20%,总仓 25%-40%")
|
||||
if temp >= 30:
|
||||
return ("弱势防守", "中高", "以观察池为主,减少追高,只等强确认", "单票 0%-10%,总仓 0%-25%")
|
||||
return ("退潮冰点", "高", "暂停主动出手,等待市场修复和主线重新出现", "空仓或极低仓观察")
|
||||
|
||||
|
||||
def _choose_strategy_mode(
|
||||
temp: float, sectors: list[SectorInfo], recommendations: list[Recommendation]
|
||||
) -> str:
|
||||
top = sectors[:5]
|
||||
strong = [s for s in top if _sector_pct(s) >= 2 and _sector_breadth(s) > 0]
|
||||
active = [s for s in top if _sector_pct(s) >= 1 and (_sector_turnover(s) >= 2 or s.limit_up_count >= 2)]
|
||||
end_stage = [s for s in top if s.stage == "end" and _sector_pct(s) > 0]
|
||||
|
||||
if temp >= 65 and len(strong) >= 2:
|
||||
return "顺主线做强势确认"
|
||||
if temp >= 55 and active:
|
||||
return "围绕强板块做回踩确认"
|
||||
if temp < 45 or end_stage:
|
||||
return "缩仓观察,回避后排追高"
|
||||
if recommendations:
|
||||
return "观察池跟踪,等待触发"
|
||||
return "防守观察"
|
||||
|
||||
|
||||
def _build_strategy_focus(
|
||||
temp: float, sectors: list[SectorInfo], recommendations: list[Recommendation]
|
||||
) -> list[StrategyFocus]:
|
||||
focus: list[StrategyFocus] = []
|
||||
signal_counts: dict[str, int] = {}
|
||||
for rec in recommendations:
|
||||
signal_counts[rec.entry_signal_type] = signal_counts.get(rec.entry_signal_type, 0) + 1
|
||||
|
||||
top_signal = max(signal_counts, key=signal_counts.get) if signal_counts else ""
|
||||
signal_label = {
|
||||
"breakout": "突破型",
|
||||
"breakout_confirm": "突破确认型",
|
||||
"pullback": "回踩型",
|
||||
"launch": "启动型",
|
||||
"reversal": "反转型",
|
||||
}.get(top_signal, "观察型")
|
||||
|
||||
focus.append(StrategyFocus(
|
||||
label=signal_label,
|
||||
description=f"当前推荐中该类型占比较高,适合作为今日主要观察模板。",
|
||||
))
|
||||
|
||||
if sectors:
|
||||
main = sectors[0]
|
||||
sector_pct = _sector_pct(main)
|
||||
breadth = _sector_breadth(main)
|
||||
turnover = _sector_turnover(main)
|
||||
focus.append(StrategyFocus(
|
||||
label=f"{main.sector_name} 主线跟踪",
|
||||
description=(
|
||||
f"当前涨幅 {sector_pct:+.2f}% ,广度 {breadth:+d},换手 {turnover:.1f}% ,"
|
||||
f"阶段 {main.stage},优先确认资金是否延续。"
|
||||
),
|
||||
))
|
||||
|
||||
if len(sectors) > 1:
|
||||
runner_up = sectors[1]
|
||||
focus.append(StrategyFocus(
|
||||
label=f"{runner_up.sector_name} 轮动监控",
|
||||
description=(
|
||||
f"强度分 {_sector_strength_score(runner_up)},"
|
||||
f"若涨幅继续扩大且广度转强,可切入今日第二梯队。"
|
||||
),
|
||||
))
|
||||
|
||||
if temp < 45:
|
||||
focus.append(StrategyFocus(
|
||||
label="防守优先",
|
||||
description="市场温度不足,推荐只作为观察池,不宜扩大仓位。",
|
||||
))
|
||||
elif sectors and _sector_pct(sectors[0]) < 1:
|
||||
focus.append(StrategyFocus(
|
||||
label="等一致性强化",
|
||||
description="板块领涨幅度仍有限,优先等主线扩散和个股触发后再出手。",
|
||||
))
|
||||
|
||||
return focus
|
||||
|
||||
|
||||
def _sector_focus(sector: SectorInfo) -> StrategySectorFocus:
|
||||
stage_view = {
|
||||
"early": "早期,重点观察资金是否连续流入",
|
||||
"mid": "中期,适合寻找回踩或突破确认",
|
||||
"late": "后期,防止加速后分歧",
|
||||
"end": "末期,谨慎追高",
|
||||
}.get(sector.stage, "阶段不明,等待确认")
|
||||
|
||||
return StrategySectorFocus(
|
||||
sector_name=sector.sector_name,
|
||||
stage=sector.stage,
|
||||
heat_score=sector.heat_score,
|
||||
pct_change=_sector_pct(sector),
|
||||
limit_up_count=sector.limit_up_count,
|
||||
turnover_rate=_sector_turnover(sector),
|
||||
up_count=_sector_up_count(sector),
|
||||
down_count=_sector_down_count(sector),
|
||||
data_mode=sector.data_mode,
|
||||
view=(
|
||||
f"{stage_view};当前涨幅 {_sector_pct(sector):+.2f}%"
|
||||
+ (
|
||||
f",上涨/下跌 {_sector_up_count(sector)}/{_sector_down_count(sector)}"
|
||||
if sector.is_realtime else ""
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _build_avoid_rules(
|
||||
temp: float, sectors: list[SectorInfo], recommendations: list[Recommendation]
|
||||
) -> list[str]:
|
||||
rules = []
|
||||
top = sectors[:5]
|
||||
if temp < 45:
|
||||
rules.append("市场温度低于45时,不追突破首日,只等次日确认或回踩。")
|
||||
if any(s.stage == "end" for s in top):
|
||||
rules.append("板块进入末期时,降低同板块追高标的权重。")
|
||||
if top and _sector_pct(top[0]) < 1:
|
||||
rules.append("主线板块涨幅不足1%时,不把局部异动当成全面进攻信号。")
|
||||
if any(_sector_breadth(s) < 0 for s in top[:3] if s.is_realtime):
|
||||
rules.append("板块涨幅与广度背离时,优先回避后排补涨和冲高追单。")
|
||||
if any(_sector_turnover(s) > 8 and s.stage in ("late", "end") for s in top):
|
||||
rules.append("高换手叠加板块后期阶段时,防止情绪过热后的快速分歧。")
|
||||
if any(r.position_score < 35 for r in recommendations):
|
||||
rules.append("位置安全分低于35的标的,只观察不主动追入。")
|
||||
if not rules:
|
||||
rules.append("推荐失效条件触发后不补仓,等待下一次扫描重新确认。")
|
||||
return rules
|
||||
|
||||
|
||||
def _build_iteration_notes(performance: dict, recommendations: list[Recommendation]) -> list[str]:
|
||||
notes = []
|
||||
tracked = performance.get("tracked", 0) or 0
|
||||
win_rate = performance.get("win_rate", 0) or 0
|
||||
avg_return = performance.get("avg_return", 0) or 0
|
||||
hit_stop = performance.get("hit_stop_count", 0) or 0
|
||||
hit_target = performance.get("hit_target_count", 0) or 0
|
||||
|
||||
if tracked < 10:
|
||||
notes.append("跟踪样本不足,暂不自动调整策略权重,优先积累推荐生命周期数据。")
|
||||
else:
|
||||
if win_rate < 45:
|
||||
notes.append("近期胜率偏低,下轮应提高入场确认门槛,减少弱势环境下的突破型推荐。")
|
||||
if avg_return < 0:
|
||||
notes.append("平均收益为负,建议收紧止损触发和推荐失效条件。")
|
||||
if hit_stop > hit_target:
|
||||
notes.append("止损次数多于命中目标,优先复查追高和板块末期惩罚是否不足。")
|
||||
|
||||
actionable_count = sum(1 for r in recommendations if r.action_plan == "可操作")
|
||||
if actionable_count > 5:
|
||||
notes.append("可操作标的偏多,前端应按板块集中度和评分排序控制关注数量。")
|
||||
|
||||
return notes
|
||||
|
||||
|
||||
async def _generate_ai_review(
|
||||
board: StrategyBoard,
|
||||
recommendations: list[Recommendation],
|
||||
performance: dict,
|
||||
) -> str:
|
||||
"""用 LLM 生成简短的策略解释,不参与硬性交易决策。"""
|
||||
from app.llm.client import chat_completion
|
||||
|
||||
rec_lines = "\n".join(
|
||||
f"- {r.name}({r.ts_code}) {r.action_plan} {r.entry_signal_type} "
|
||||
f"评分{r.score} 仓位{r.suggested_position_pct}% 触发: {r.trigger_condition}"
|
||||
for r in recommendations[:8]
|
||||
) or "暂无推荐"
|
||||
|
||||
user_msg = f"""请基于以下系统数据,生成一段今日A股策略作战说明,要求:
|
||||
1. 明确区分市场事实、策略推断和风险约束;
|
||||
2. 不要承诺收益,不要给绝对化买卖结论;
|
||||
3. 最多220字,中文。
|
||||
|
||||
市场状态: {board.market_regime}
|
||||
风险等级: {board.risk_level}
|
||||
操作倾向: {board.action_bias}
|
||||
仓位建议: {board.position_suggestion}
|
||||
推荐策略: {board.recommended_mode}
|
||||
历史跟踪: 胜率{performance.get('win_rate', 0)}%, 平均收益{performance.get('avg_return', 0)}%
|
||||
|
||||
推荐摘要:
|
||||
{rec_lines}
|
||||
"""
|
||||
|
||||
resp = await chat_completion([
|
||||
{"role": "system", "content": "你是一位谨慎的A股交易研究助手,擅长把量化结果转成可执行但有风险边界的策略说明。"},
|
||||
{"role": "user", "content": user_msg},
|
||||
])
|
||||
return resp.content.strip() if resp and resp.content else ""
|
||||
@ -1,421 +1,25 @@
|
||||
"""策略配置中心
|
||||
"""策略配置 stub
|
||||
|
||||
把可迭代的策略参数和 Prompt 版本持久化到数据库。
|
||||
代码里的默认策略只作为兜底;一旦数据库有激活配置,下一轮扫描直接读取配置。
|
||||
策略参数现在直接写在 strategy_selector.py 中。
|
||||
此模块仅保留 ensure_default_configs (main.py 启动调用) 和
|
||||
load_active_strategy_profile (selector 调用) 的兼容接口。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.db import tables
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_FIELDS = {
|
||||
"name",
|
||||
"description",
|
||||
"entry_signal_priority",
|
||||
"score_weights",
|
||||
"min_score",
|
||||
"buy_threshold",
|
||||
"max_position_pct",
|
||||
"allow_trading",
|
||||
"actionable_limit",
|
||||
"watch_limit",
|
||||
"target_focus_sectors",
|
||||
"market_stance",
|
||||
"decision_note",
|
||||
"notes",
|
||||
}
|
||||
|
||||
PROMPT_DEFAULT_KEYS = {
|
||||
"stock_prefilter": "STOCK_PREFILTER_PROMPT",
|
||||
"single_stock_analysis": "SINGLE_STOCK_ANALYSIS_PROMPT",
|
||||
"strategy_iteration": "STRATEGY_ITERATION_PROMPT",
|
||||
}
|
||||
|
||||
|
||||
def profile_to_config(profile) -> dict[str, Any]:
|
||||
data = profile.model_dump() if hasattr(profile, "model_dump") else dict(profile)
|
||||
return {key: data[key] for key in CONFIG_FIELDS if key in data}
|
||||
|
||||
|
||||
def apply_config_to_profile(profile, config: dict[str, Any] | None, generated_by: str = "config"):
|
||||
if not config:
|
||||
return profile
|
||||
updated = profile.model_copy(deep=True)
|
||||
for key, value in config.items():
|
||||
if key in CONFIG_FIELDS and hasattr(updated, key):
|
||||
setattr(updated, key, value)
|
||||
updated.generated_by = generated_by
|
||||
return updated
|
||||
async def ensure_default_configs() -> None:
|
||||
"""兼容接口 — 启动时不再需要写策略配置到数据库。"""
|
||||
pass
|
||||
|
||||
|
||||
async def load_active_strategy_profile(profile):
|
||||
row = await _load_active_strategy_row(profile.strategy_id)
|
||||
if not row:
|
||||
return profile
|
||||
config = _json_loads(row["config_json"], {})
|
||||
updated = apply_config_to_profile(profile, config, generated_by=f"config:v{row['version']}")
|
||||
updated.feedback_applied = True
|
||||
updated.feedback_notes = [
|
||||
f"策略配置版本 v{row['version']} ({row['source']}) 已生效",
|
||||
row["change_reason"] or "使用配置中心激活版本",
|
||||
]
|
||||
return updated
|
||||
|
||||
|
||||
async def get_active_strategy_configs() -> list[dict]:
|
||||
await ensure_default_configs()
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT * FROM strategy_configs "
|
||||
"WHERE is_active = 1 "
|
||||
"ORDER BY strategy_id ASC, version DESC"
|
||||
)
|
||||
)
|
||||
return [_format_strategy_row(row._mapping) for row in result.fetchall()]
|
||||
|
||||
|
||||
async def get_recent_config_changes(limit: int = 20) -> list[dict]:
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT * FROM strategy_config_changes "
|
||||
"ORDER BY id DESC LIMIT :limit"
|
||||
),
|
||||
{"limit": limit},
|
||||
)
|
||||
return [_format_change_row(row._mapping) for row in result.fetchall()]
|
||||
|
||||
|
||||
async def get_active_prompt_configs() -> list[dict]:
|
||||
await ensure_default_configs()
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT * FROM prompt_configs "
|
||||
"WHERE is_active = 1 "
|
||||
"ORDER BY prompt_key ASC, version DESC"
|
||||
)
|
||||
)
|
||||
return [_format_prompt_row(row._mapping) for row in result.fetchall()]
|
||||
"""直接返回规则 profile,不再从数据库加载配置覆盖。"""
|
||||
return profile
|
||||
|
||||
|
||||
async def get_prompt_content(prompt_key: str, default: str) -> str:
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT content FROM prompt_configs "
|
||||
"WHERE prompt_key = :key AND is_active = 1 "
|
||||
"ORDER BY version DESC LIMIT 1"
|
||||
),
|
||||
{"key": prompt_key},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return str(row._mapping["content"]) if row else default
|
||||
|
||||
|
||||
async def create_active_strategy_config(
|
||||
strategy_id: str,
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
source: str,
|
||||
reason: str,
|
||||
evidence: dict[str, Any] | None = None,
|
||||
change_type: str = "manual",
|
||||
) -> dict:
|
||||
"""写入一个新的激活策略配置版本,并记录变更。"""
|
||||
async with get_db() as db:
|
||||
base = await _load_active_strategy_row(strategy_id, db=db)
|
||||
version = int(base["version"]) + 1 if base else 1
|
||||
before = _json_loads(base["config_json"], {}) if base else {}
|
||||
diff = _build_diff(before, config)
|
||||
|
||||
await db.execute(
|
||||
text("UPDATE strategy_configs SET is_active = 0 WHERE strategy_id = :sid"),
|
||||
{"sid": strategy_id},
|
||||
)
|
||||
await db.execute(
|
||||
tables.strategy_configs_table.insert().values(
|
||||
strategy_id=strategy_id,
|
||||
version=version,
|
||||
config_json=json.dumps(config, ensure_ascii=False),
|
||||
is_active=True,
|
||||
source=source,
|
||||
change_reason=reason,
|
||||
evidence_json=json.dumps(evidence or {}, ensure_ascii=False),
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
tables.strategy_config_changes_table.insert().values(
|
||||
change_type=change_type,
|
||||
status="applied",
|
||||
strategy_id=strategy_id,
|
||||
base_version=int(base["version"]) if base else 0,
|
||||
new_version=version,
|
||||
diff_json=json.dumps(diff, ensure_ascii=False),
|
||||
evidence_json=json.dumps(evidence or {}, ensure_ascii=False),
|
||||
reason=reason,
|
||||
applied_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
row = await _load_active_strategy_row(strategy_id)
|
||||
return _format_strategy_row(row)
|
||||
|
||||
|
||||
async def rollback_strategy_config(strategy_id: str) -> dict:
|
||||
"""回滚到当前策略的上一个版本。"""
|
||||
async with get_db() as db:
|
||||
active = await _load_active_strategy_row(strategy_id, db=db)
|
||||
if not active:
|
||||
raise ValueError("当前策略没有激活配置")
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT * FROM strategy_configs "
|
||||
"WHERE strategy_id = :sid AND version < :version "
|
||||
"ORDER BY version DESC LIMIT 1"
|
||||
),
|
||||
{"sid": strategy_id, "version": active["version"]},
|
||||
)
|
||||
previous_row = result.fetchone()
|
||||
if not previous_row:
|
||||
raise ValueError("没有可回滚的上一版本")
|
||||
current = active
|
||||
previous = previous_row._mapping
|
||||
await db.execute(
|
||||
text("UPDATE strategy_configs SET is_active = 0 WHERE strategy_id = :sid"),
|
||||
{"sid": strategy_id},
|
||||
)
|
||||
await db.execute(
|
||||
text("UPDATE strategy_configs SET is_active = 1, source = 'rollback' WHERE id = :id"),
|
||||
{"id": previous["id"]},
|
||||
)
|
||||
await db.execute(
|
||||
tables.strategy_config_changes_table.insert().values(
|
||||
change_type="rollback",
|
||||
status="applied",
|
||||
strategy_id=strategy_id,
|
||||
base_version=int(current["version"]),
|
||||
new_version=int(previous["version"]),
|
||||
diff_json=json.dumps(
|
||||
_build_diff(_json_loads(current["config_json"], {}), _json_loads(previous["config_json"], {})),
|
||||
ensure_ascii=False,
|
||||
),
|
||||
reason=f"回滚 {strategy_id} 到 v{previous['version']}",
|
||||
applied_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
row = await _load_active_strategy_row(strategy_id)
|
||||
return _format_strategy_row(row)
|
||||
|
||||
|
||||
async def ensure_default_configs() -> None:
|
||||
"""首次启动时把代码默认策略和默认 Prompt 种子写入数据库。"""
|
||||
from app.llm.strategy_selector import get_strategy_profile_by_id
|
||||
from app.llm import prompts
|
||||
|
||||
strategy_ids = ["breakout_attack", "pullback_rotation", "launch_probe", "defensive_watch"]
|
||||
async with get_db() as db:
|
||||
for strategy_id in strategy_ids:
|
||||
count = (await db.execute(
|
||||
text("SELECT COUNT(*) FROM strategy_configs WHERE strategy_id = :sid"),
|
||||
{"sid": strategy_id},
|
||||
)).scalar() or 0
|
||||
if count:
|
||||
continue
|
||||
profile = get_strategy_profile_by_id(strategy_id)
|
||||
await db.execute(
|
||||
tables.strategy_configs_table.insert().values(
|
||||
strategy_id=strategy_id,
|
||||
version=1,
|
||||
config_json=json.dumps(profile_to_config(profile), ensure_ascii=False),
|
||||
is_active=True,
|
||||
source="default_seed",
|
||||
change_reason="初始化默认策略配置",
|
||||
)
|
||||
)
|
||||
|
||||
prompt_defaults = {
|
||||
"stock_prefilter": getattr(prompts, "STOCK_PREFILTER_PROMPT", ""),
|
||||
"single_stock_analysis": getattr(prompts, "SINGLE_STOCK_ANALYSIS_PROMPT", ""),
|
||||
"strategy_iteration": getattr(prompts, "STRATEGY_ITERATION_PROMPT", ""),
|
||||
}
|
||||
for prompt_key, content in prompt_defaults.items():
|
||||
if not content:
|
||||
continue
|
||||
count = (await db.execute(
|
||||
text("SELECT COUNT(*) FROM prompt_configs WHERE prompt_key = :key"),
|
||||
{"key": prompt_key},
|
||||
)).scalar() or 0
|
||||
if count:
|
||||
continue
|
||||
await db.execute(
|
||||
tables.prompt_configs_table.insert().values(
|
||||
prompt_key=prompt_key,
|
||||
version=1,
|
||||
content=content,
|
||||
is_active=True,
|
||||
source="default_seed",
|
||||
change_reason="初始化默认 Prompt 配置",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def maybe_auto_apply_review_adjustment(report: dict) -> dict | None:
|
||||
"""根据复盘报告做小幅自动配置调整。
|
||||
|
||||
大幅结构调整仍只进入报告建议,不自动改配置。
|
||||
"""
|
||||
sample_size = int(report.get("sample_size") or 0)
|
||||
if sample_size < 10:
|
||||
return None
|
||||
if await _has_auto_change_today():
|
||||
return None
|
||||
|
||||
for suggestion in report.get("adjustment_suggestions", []) or []:
|
||||
strategy_id = suggestion.get("target", "")
|
||||
if strategy_id not in {"breakout_attack", "pullback_rotation", "launch_probe", "defensive_watch"}:
|
||||
continue
|
||||
active = await _load_active_strategy_row(strategy_id)
|
||||
if not active:
|
||||
continue
|
||||
config = _json_loads(active["config_json"], {})
|
||||
changed = _apply_small_adjustment(config, suggestion.get("action", ""))
|
||||
if not changed:
|
||||
continue
|
||||
evidence = {
|
||||
"sample_size": sample_size,
|
||||
"summary": report.get("summary", ""),
|
||||
"suggestion": suggestion,
|
||||
}
|
||||
return await create_active_strategy_config(
|
||||
strategy_id,
|
||||
config,
|
||||
source="auto_review",
|
||||
reason=suggestion.get("reason", "复盘触发小幅自动配置调整"),
|
||||
evidence=evidence,
|
||||
change_type="auto_applied",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _load_active_strategy_row(strategy_id: str, db=None):
|
||||
own_session = db is None
|
||||
if own_session:
|
||||
async with get_db() as session:
|
||||
return await _load_active_strategy_row(strategy_id, db=session)
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT * FROM strategy_configs "
|
||||
"WHERE strategy_id = :sid AND is_active = 1 "
|
||||
"ORDER BY version DESC LIMIT 1"
|
||||
),
|
||||
{"sid": strategy_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row._mapping if row else None
|
||||
|
||||
|
||||
async def _has_auto_change_today() -> bool:
|
||||
async with get_db() as db:
|
||||
count = (await db.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM strategy_config_changes "
|
||||
"WHERE change_type = 'auto_applied' "
|
||||
"AND date(created_at) = date('now', 'localtime')"
|
||||
)
|
||||
)).scalar() or 0
|
||||
return count > 0
|
||||
|
||||
|
||||
def _apply_small_adjustment(config: dict[str, Any], action: str) -> bool:
|
||||
if action == "tighten":
|
||||
config["buy_threshold"] = min(float(config.get("buy_threshold", 60)) + 1, 80)
|
||||
config["max_position_pct"] = max(float(config.get("max_position_pct", 10)) - 5, 0)
|
||||
config["actionable_limit"] = max(int(config.get("actionable_limit", 1)) - 1, 0)
|
||||
return True
|
||||
if action == "promote":
|
||||
config["buy_threshold"] = max(float(config.get("buy_threshold", 60)) - 1, float(config.get("min_score", 0)))
|
||||
config["watch_limit"] = min(int(config.get("watch_limit", 3)) + 1, 8)
|
||||
return True
|
||||
if action == "reduce":
|
||||
config["buy_threshold"] = min(float(config.get("buy_threshold", 60)) + 1, 80)
|
||||
config["watch_limit"] = max(int(config.get("watch_limit", 3)) - 1, 1)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _build_diff(before: dict[str, Any], after: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
diff: dict[str, dict[str, Any]] = {}
|
||||
for key in sorted(set(before) | set(after)):
|
||||
if before.get(key) != after.get(key):
|
||||
diff[key] = {"from": before.get(key), "to": after.get(key)}
|
||||
return diff
|
||||
|
||||
|
||||
def _json_loads(value: str | None, default):
|
||||
try:
|
||||
return json.loads(value or "")
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _format_strategy_row(row) -> dict:
|
||||
if not row:
|
||||
return {}
|
||||
return {
|
||||
"id": row["id"],
|
||||
"strategy_id": row["strategy_id"],
|
||||
"version": row["version"],
|
||||
"config": _json_loads(row["config_json"], {}),
|
||||
"is_active": bool(row["is_active"]),
|
||||
"source": row["source"] or "",
|
||||
"change_reason": row["change_reason"] or "",
|
||||
"evidence": _json_loads(row["evidence_json"], {}),
|
||||
"effective_from": str(row["effective_from"] or ""),
|
||||
"created_at": str(row["created_at"] or ""),
|
||||
}
|
||||
|
||||
|
||||
def _format_prompt_row(row) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"prompt_key": row["prompt_key"],
|
||||
"version": row["version"],
|
||||
"content": row["content"],
|
||||
"is_active": bool(row["is_active"]),
|
||||
"source": row["source"] or "",
|
||||
"change_reason": row["change_reason"] or "",
|
||||
"evidence": _json_loads(row["evidence_json"], {}),
|
||||
"created_at": str(row["created_at"] or ""),
|
||||
}
|
||||
|
||||
|
||||
def _format_change_row(row) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"change_type": row["change_type"],
|
||||
"status": row["status"],
|
||||
"strategy_id": row["strategy_id"] or "",
|
||||
"prompt_key": row["prompt_key"] or "",
|
||||
"base_version": row["base_version"] or 0,
|
||||
"new_version": row["new_version"] or 0,
|
||||
"diff": _json_loads(row["diff_json"], {}),
|
||||
"evidence": _json_loads(row["evidence_json"], {}),
|
||||
"reason": row["reason"] or "",
|
||||
"created_at": str(row["created_at"] or ""),
|
||||
"applied_at": str(row["applied_at"] or ""),
|
||||
}
|
||||
"""直接返回默认 prompt,不再从数据库加载。"""
|
||||
return default
|
||||
|
||||
@ -1,576 +0,0 @@
|
||||
"""策略复盘迭代 Agent
|
||||
|
||||
基于推荐生命周期表现,输出可审查的策略调整建议。
|
||||
不直接修改策略参数,只给出建议和证据。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def build_strategy_iteration_report(
|
||||
limit: int = 50,
|
||||
include_llm: bool = False,
|
||||
apply_auto_config: bool = False,
|
||||
) -> dict:
|
||||
rows = await _load_recent_tracking(limit)
|
||||
rule_report = _build_rule_report(rows)
|
||||
|
||||
auto_change = None
|
||||
if apply_auto_config:
|
||||
from app.llm.strategy_config import maybe_auto_apply_review_adjustment
|
||||
try:
|
||||
auto_change = await maybe_auto_apply_review_adjustment(rule_report)
|
||||
except Exception as e:
|
||||
logger.warning(f"自动策略配置调整失败: {e}")
|
||||
if auto_change:
|
||||
rule_report["auto_config_change"] = auto_change
|
||||
rule_report["generated_by"] = "rules+auto_config"
|
||||
|
||||
if include_llm and settings.deepseek_api_key and rows:
|
||||
ai_text = await _generate_ai_iteration(rule_report, rows)
|
||||
if ai_text:
|
||||
rule_report["ai_analysis"] = ai_text
|
||||
rule_report["generated_by"] = "rules+llm"
|
||||
|
||||
return rule_report
|
||||
|
||||
|
||||
async def build_strategy_feedback_controls(limit: int = 50) -> dict:
|
||||
rows = await _load_recent_tracking(limit)
|
||||
report = _build_rule_report(rows)
|
||||
return _derive_feedback_controls(report)
|
||||
|
||||
|
||||
async def _load_recent_tracking(limit: int) -> list[dict]:
|
||||
from sqlalchemy import text
|
||||
from app.db.database import get_db
|
||||
|
||||
async with get_db() as db:
|
||||
rec_columns = await _get_table_columns(db, "recommendations")
|
||||
tracking_columns = await _get_table_columns(db, "recommendation_tracking")
|
||||
r_action_plan = _column_or_default(rec_columns, "action_plan", "'观察'", "r")
|
||||
r_position_score = _column_or_default(rec_columns, "position_score", "50", "r")
|
||||
r_lifecycle_status = _column_or_default(rec_columns, "lifecycle_status", "'candidate'", "r")
|
||||
r_capital_score = _column_or_default(rec_columns, "capital_score", "0", "r")
|
||||
r_recall_tags = _column_or_default(rec_columns, "recall_tags", "'[]'", "r")
|
||||
t_max_return = _column_or_default(tracking_columns, "max_return_pct", "t.pct_from_entry", "t")
|
||||
t_max_drawdown = _column_or_default(tracking_columns, "max_drawdown_pct", "t.pct_from_entry", "t")
|
||||
t_days_since = _column_or_default(tracking_columns, "days_since_recommendation", "0", "t")
|
||||
t_close_reason = _column_or_default(tracking_columns, "close_reason", "''", "t")
|
||||
t_review_note = _column_or_default(tracking_columns, "review_note", "''", "t")
|
||||
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT r.id, r.ts_code, r.name, r.sector, r.strategy, r.entry_signal_type, "
|
||||
f"{r_action_plan} AS action_plan, r.score, r.market_temp_score, r.sector_score, "
|
||||
f"{r_capital_score} AS capital_score, {r_position_score} AS position_score, "
|
||||
f"{r_lifecycle_status} AS lifecycle_status, {r_recall_tags} AS recall_tags, "
|
||||
"r.entry_price, r.target_price, r.stop_loss, r.created_at, "
|
||||
f"t.pct_from_entry, {t_max_return} AS max_return_pct, {t_max_drawdown} AS max_drawdown_pct, "
|
||||
f"{t_days_since} AS days_since_recommendation, {t_close_reason} AS close_reason, "
|
||||
f"{t_review_note} AS review_note, t.track_date "
|
||||
"FROM recommendations r "
|
||||
"LEFT JOIN ("
|
||||
" SELECT t.* FROM recommendation_tracking t "
|
||||
" INNER JOIN ("
|
||||
" SELECT recommendation_id, MAX(id) AS max_id "
|
||||
" FROM recommendation_tracking GROUP BY recommendation_id"
|
||||
" ) latest ON t.id = latest.max_id"
|
||||
") t ON t.recommendation_id = r.id "
|
||||
"ORDER BY r.created_at DESC LIMIT :limit"
|
||||
),
|
||||
{"limit": limit},
|
||||
)
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
|
||||
async def _get_table_columns(db, table_name: str) -> set[str]:
|
||||
from sqlalchemy import text
|
||||
|
||||
result = await db.execute(text(f"PRAGMA table_info({table_name})"))
|
||||
return {row._mapping["name"] for row in result.fetchall()}
|
||||
|
||||
|
||||
def _column_or_default(columns: set[str], column_name: str, default_sql: str, alias: str = "") -> str:
|
||||
if column_name in columns:
|
||||
return f"{alias}.{column_name}" if alias else column_name
|
||||
return default_sql
|
||||
|
||||
|
||||
def _build_rule_report(rows: list[dict]) -> dict:
|
||||
if not rows:
|
||||
return {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"sample_size": 0,
|
||||
"summary": "暂无可复盘的推荐样本。",
|
||||
"strategy_stats": [],
|
||||
"signal_stats": [],
|
||||
"failure_patterns": ["样本不足,先积累推荐生命周期数据。"],
|
||||
"review_windows": [],
|
||||
"failure_cases": [],
|
||||
"success_patterns": [],
|
||||
"adjustment_suggestions": [],
|
||||
"agent_patch_prompts": [],
|
||||
"auto_config_change": None,
|
||||
"ai_analysis": "",
|
||||
"generated_by": "rules",
|
||||
}
|
||||
|
||||
tracked_rows = [r for r in rows if r.get("pct_from_entry") is not None]
|
||||
strategy_stats = _group_stats(tracked_rows, "strategy")
|
||||
signal_stats = _group_stats(tracked_rows, "entry_signal_type")
|
||||
failure_patterns = _detect_failure_patterns(tracked_rows)
|
||||
suggestions = _build_adjustment_suggestions(strategy_stats, signal_stats, failure_patterns, len(tracked_rows))
|
||||
review_windows = _build_review_windows(tracked_rows)
|
||||
failure_cases = _build_failure_cases(tracked_rows)
|
||||
success_patterns = _build_success_patterns(tracked_rows)
|
||||
patch_prompts = _build_agent_patch_prompts(
|
||||
strategy_stats=strategy_stats,
|
||||
signal_stats=signal_stats,
|
||||
failure_patterns=failure_patterns,
|
||||
failure_cases=failure_cases,
|
||||
sample_size=len(tracked_rows),
|
||||
)
|
||||
|
||||
wins = sum(1 for r in tracked_rows if (r.get("pct_from_entry") or 0) > 0)
|
||||
avg_return = _avg([r.get("pct_from_entry") for r in tracked_rows])
|
||||
avg_drawdown = _avg([r.get("max_drawdown_pct") for r in tracked_rows])
|
||||
win_rate = round(wins / len(tracked_rows) * 100, 1) if tracked_rows else 0
|
||||
|
||||
return {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"sample_size": len(tracked_rows),
|
||||
"summary": (
|
||||
f"最近 {len(rows)} 条推荐中,{len(tracked_rows)} 条已有跟踪数据;"
|
||||
f"胜率 {win_rate}%,平均收益 {avg_return}%,平均最大回撤 {avg_drawdown}%。"
|
||||
),
|
||||
"strategy_stats": strategy_stats,
|
||||
"signal_stats": signal_stats,
|
||||
"failure_patterns": failure_patterns,
|
||||
"review_windows": review_windows,
|
||||
"failure_cases": failure_cases,
|
||||
"success_patterns": success_patterns,
|
||||
"adjustment_suggestions": suggestions,
|
||||
"agent_patch_prompts": patch_prompts,
|
||||
"auto_config_change": None,
|
||||
"ai_analysis": "",
|
||||
"generated_by": "rules",
|
||||
}
|
||||
|
||||
|
||||
def _group_stats(rows: list[dict], key: str) -> list[dict]:
|
||||
groups: dict[str, list[dict]] = defaultdict(list)
|
||||
for row in rows:
|
||||
groups[row.get(key) or "unknown"].append(row)
|
||||
|
||||
stats = []
|
||||
for name, items in groups.items():
|
||||
wins = sum(1 for r in items if (r.get("pct_from_entry") or 0) > 0)
|
||||
hit_stop = sum(1 for r in items if r.get("close_reason") == "hit_stop_loss")
|
||||
hit_target = sum(1 for r in items if r.get("close_reason") == "hit_target")
|
||||
stats.append({
|
||||
"name": name,
|
||||
"count": len(items),
|
||||
"win_rate": round(wins / len(items) * 100, 1) if items else 0,
|
||||
"avg_return": _avg([r.get("pct_from_entry") for r in items]),
|
||||
"avg_max_return": _avg([r.get("max_return_pct") for r in items]),
|
||||
"avg_max_drawdown": _avg([r.get("max_drawdown_pct") for r in items]),
|
||||
"hit_target": hit_target,
|
||||
"hit_stop": hit_stop,
|
||||
})
|
||||
|
||||
stats.sort(key=lambda x: (x["count"], x["avg_return"]), reverse=True)
|
||||
return stats
|
||||
|
||||
|
||||
def _detect_failure_patterns(rows: list[dict]) -> list[str]:
|
||||
patterns = []
|
||||
if not rows:
|
||||
return ["暂无跟踪样本。"]
|
||||
|
||||
weak_market_losses = [
|
||||
r for r in rows
|
||||
if (r.get("market_temp_score") or 0) < 45 and (r.get("pct_from_entry") or 0) < 0
|
||||
]
|
||||
if len(weak_market_losses) >= 2:
|
||||
patterns.append("弱势市场中仍有亏损推荐,低温环境下应进一步减少 BUY 或提高确认门槛。")
|
||||
|
||||
high_position_losses = [
|
||||
r for r in rows
|
||||
if (r.get("position_score") or 50) < 40 and (r.get("pct_from_entry") or 0) < 0
|
||||
]
|
||||
if len(high_position_losses) >= 2:
|
||||
patterns.append("位置安全分偏低的推荐亏损较多,追高惩罚需要增强。")
|
||||
|
||||
stop_losses = [r for r in rows if r.get("close_reason") == "hit_stop_loss"]
|
||||
if len(stop_losses) >= 2:
|
||||
patterns.append("触发止损样本偏多,需要复查止损位置和入场触发条件是否过宽。")
|
||||
|
||||
expired_flat = [
|
||||
r for r in rows
|
||||
if r.get("close_reason") in ("review_expired_flat", "review_expired_loss")
|
||||
]
|
||||
if len(expired_flat) >= 3:
|
||||
patterns.append("多只推荐到期未形成有效进攻,观察池转可操作的条件需要更严格。")
|
||||
|
||||
if not patterns:
|
||||
patterns.append("暂无明显集中失败模式,继续积累样本并按策略分组观察。")
|
||||
return patterns
|
||||
|
||||
|
||||
def _build_adjustment_suggestions(
|
||||
strategy_stats: list[dict],
|
||||
signal_stats: list[dict],
|
||||
failure_patterns: list[str],
|
||||
sample_size: int,
|
||||
) -> list[dict]:
|
||||
suggestions = []
|
||||
|
||||
if sample_size < 10:
|
||||
return [{
|
||||
"target": "全局策略",
|
||||
"action": "observe",
|
||||
"reason": "跟踪样本少于10条,暂不建议调整参数。",
|
||||
"confidence": "low",
|
||||
}]
|
||||
|
||||
for stat in strategy_stats:
|
||||
if stat["count"] >= 3 and stat["win_rate"] < 40 and stat["avg_return"] < 0:
|
||||
suggestions.append({
|
||||
"target": stat["name"],
|
||||
"action": "tighten",
|
||||
"reason": f"{stat['name']} 胜率{stat['win_rate']}%,平均收益{stat['avg_return']}%,建议提高买入门槛。",
|
||||
"confidence": "medium",
|
||||
})
|
||||
elif stat["count"] >= 3 and stat["win_rate"] >= 60 and stat["avg_return"] > 1:
|
||||
suggestions.append({
|
||||
"target": stat["name"],
|
||||
"action": "promote",
|
||||
"reason": f"{stat['name']} 近期表现较好,可在相似市场环境下优先使用。",
|
||||
"confidence": "medium",
|
||||
})
|
||||
|
||||
for stat in signal_stats:
|
||||
if stat["count"] >= 3 and stat["avg_max_drawdown"] < -5:
|
||||
suggestions.append({
|
||||
"target": stat["name"],
|
||||
"action": "reduce",
|
||||
"reason": f"{stat['name']} 平均最大回撤{stat['avg_max_drawdown']}%,建议降低排序权重或增加位置过滤。",
|
||||
"confidence": "medium",
|
||||
})
|
||||
|
||||
if any("弱势市场" in p for p in failure_patterns):
|
||||
suggestions.append({
|
||||
"target": "defensive_watch",
|
||||
"action": "tighten",
|
||||
"reason": "弱势市场亏损样本集中,防守策略下应只保留观察池,减少 BUY。",
|
||||
"confidence": "high",
|
||||
})
|
||||
|
||||
if not suggestions:
|
||||
suggestions.append({
|
||||
"target": "全局策略",
|
||||
"action": "keep",
|
||||
"reason": "当前样本未显示需要立即调整的集中问题。",
|
||||
"confidence": "medium",
|
||||
})
|
||||
|
||||
return suggestions[:6]
|
||||
|
||||
|
||||
def _build_review_windows(rows: list[dict]) -> list[dict]:
|
||||
windows = []
|
||||
for days in [3, 5, 10]:
|
||||
items = [
|
||||
r for r in rows
|
||||
if int(r.get("days_since_recommendation") or 0) >= days
|
||||
]
|
||||
if not items:
|
||||
windows.append({
|
||||
"window_days": days,
|
||||
"count": 0,
|
||||
"win_rate": 0,
|
||||
"avg_return": 0,
|
||||
"hit_target_rate": 0,
|
||||
"hit_stop_rate": 0,
|
||||
"avg_max_return": 0,
|
||||
"avg_max_drawdown": 0,
|
||||
})
|
||||
continue
|
||||
wins = sum(1 for r in items if (r.get("pct_from_entry") or 0) > 0)
|
||||
hit_target = sum(1 for r in items if r.get("close_reason") == "hit_target")
|
||||
hit_stop = sum(1 for r in items if r.get("close_reason") == "hit_stop_loss")
|
||||
count = len(items)
|
||||
windows.append({
|
||||
"window_days": days,
|
||||
"count": count,
|
||||
"win_rate": round(wins / count * 100, 1),
|
||||
"avg_return": _avg([r.get("pct_from_entry") for r in items]),
|
||||
"hit_target_rate": round(hit_target / count * 100, 1),
|
||||
"hit_stop_rate": round(hit_stop / count * 100, 1),
|
||||
"avg_max_return": _avg([r.get("max_return_pct") for r in items]),
|
||||
"avg_max_drawdown": _avg([r.get("max_drawdown_pct") for r in items]),
|
||||
})
|
||||
return windows
|
||||
|
||||
|
||||
def _build_failure_cases(rows: list[dict]) -> list[dict]:
|
||||
failures = [
|
||||
r for r in rows
|
||||
if (r.get("pct_from_entry") or 0) < 0
|
||||
or (r.get("close_reason") in {"hit_stop_loss", "review_expired_loss", "review_expired_flat"})
|
||||
or (r.get("max_drawdown_pct") or 0) < -5
|
||||
]
|
||||
failures.sort(key=lambda r: ((r.get("pct_from_entry") or 0), (r.get("max_drawdown_pct") or 0)))
|
||||
return [_case_summary(r) for r in failures[:8]]
|
||||
|
||||
|
||||
def _build_success_patterns(rows: list[dict]) -> list[dict]:
|
||||
successes = [
|
||||
r for r in rows
|
||||
if r.get("close_reason") == "hit_target"
|
||||
or (r.get("max_return_pct") or 0) >= 3
|
||||
or (r.get("pct_from_entry") or 0) > 2
|
||||
]
|
||||
successes.sort(key=lambda r: (r.get("max_return_pct") or 0), reverse=True)
|
||||
return [_case_summary(r) for r in successes[:8]]
|
||||
|
||||
|
||||
def _case_summary(row: dict) -> dict:
|
||||
recall_tags = row.get("recall_tags") or "[]"
|
||||
try:
|
||||
tags = json.loads(recall_tags) if isinstance(recall_tags, str) else recall_tags
|
||||
except Exception:
|
||||
tags = []
|
||||
return {
|
||||
"ts_code": row.get("ts_code"),
|
||||
"name": row.get("name"),
|
||||
"sector": row.get("sector") or "",
|
||||
"strategy": row.get("strategy") or "unknown",
|
||||
"entry_signal_type": row.get("entry_signal_type") or "unknown",
|
||||
"action_plan": row.get("action_plan") or "观察",
|
||||
"score": row.get("score") or 0,
|
||||
"market_temp_score": row.get("market_temp_score") or 0,
|
||||
"sector_score": row.get("sector_score") or 0,
|
||||
"capital_score": row.get("capital_score") or 0,
|
||||
"position_score": row.get("position_score") or 50,
|
||||
"pct_from_entry": row.get("pct_from_entry") or 0,
|
||||
"max_return_pct": row.get("max_return_pct") or 0,
|
||||
"max_drawdown_pct": row.get("max_drawdown_pct") or 0,
|
||||
"days_since_recommendation": row.get("days_since_recommendation") or 0,
|
||||
"close_reason": row.get("close_reason") or "",
|
||||
"review_note": row.get("review_note") or "",
|
||||
"recall_tags": tags,
|
||||
}
|
||||
|
||||
|
||||
def _build_agent_patch_prompts(
|
||||
strategy_stats: list[dict],
|
||||
signal_stats: list[dict],
|
||||
failure_patterns: list[str],
|
||||
failure_cases: list[dict],
|
||||
sample_size: int,
|
||||
) -> list[dict]:
|
||||
if sample_size < 10:
|
||||
return []
|
||||
|
||||
prompts = []
|
||||
weak_strategy = next(
|
||||
(
|
||||
s for s in strategy_stats
|
||||
if s["count"] >= 3 and s["win_rate"] < 40 and s["avg_return"] < 0
|
||||
),
|
||||
None,
|
||||
)
|
||||
if weak_strategy:
|
||||
prompts.append(_patch_prompt(
|
||||
title=f"收紧 {weak_strategy['name']} 策略配置",
|
||||
severity="high",
|
||||
evidence=f"样本{weak_strategy['count']}条,胜率{weak_strategy['win_rate']}%,平均收益{weak_strategy['avg_return']}%。",
|
||||
target_files=["backend/app/llm/strategy_config.py", "backend/app/llm/strategy_selector.py"],
|
||||
prompt=(
|
||||
f"请基于策略复盘收紧 {weak_strategy['name']}。优先通过策略配置版本调整完成,不要改无关代码。"
|
||||
f"证据:{weak_strategy['count']}条样本,胜率{weak_strategy['win_rate']}%,平均收益{weak_strategy['avg_return']}%,"
|
||||
f"平均最大回撤{weak_strategy['avg_max_drawdown']}%。"
|
||||
"请提高 buy_threshold 1-2 分,降低 actionable_limit 或 max_position_pct,并保留回滚记录。"
|
||||
),
|
||||
))
|
||||
|
||||
weak_signal = next(
|
||||
(
|
||||
s for s in signal_stats
|
||||
if s["count"] >= 3 and s["avg_max_drawdown"] < -5
|
||||
),
|
||||
None,
|
||||
)
|
||||
if weak_signal:
|
||||
prompts.append(_patch_prompt(
|
||||
title=f"降低 {weak_signal['name']} 信号风险暴露",
|
||||
severity="medium",
|
||||
evidence=f"样本{weak_signal['count']}条,平均最大回撤{weak_signal['avg_max_drawdown']}%。",
|
||||
target_files=["backend/app/engine/screener.py", "backend/app/analysis/breakout_signals.py"],
|
||||
prompt=(
|
||||
f"请基于复盘结果降低 {weak_signal['name']} 信号的风险暴露。"
|
||||
f"证据:样本{weak_signal['count']}条,平均最大回撤{weak_signal['avg_max_drawdown']}%,"
|
||||
f"命中止损{weak_signal['hit_stop']}次。"
|
||||
"请检查该信号的入场质量、位置过滤和止损设置,给出最小代码补丁,并保持其他信号行为不变。"
|
||||
),
|
||||
))
|
||||
|
||||
if any("弱势市场" in p for p in failure_patterns):
|
||||
prompts.append(_patch_prompt(
|
||||
title="强化弱势市场防守配置",
|
||||
severity="high",
|
||||
evidence="复盘显示弱势市场亏损样本集中。",
|
||||
target_files=["backend/app/llm/strategy_config.py", "backend/app/llm/strategy_selector.py"],
|
||||
prompt=(
|
||||
"请强化弱势市场防守配置。证据:复盘显示市场温度低于45时亏损样本集中。"
|
||||
"优先把低温环境下的 allow_trading、actionable_limit、buy_threshold 做成可配置护栏,"
|
||||
"小幅收紧可自动生效,大幅禁用策略需生成待确认变更。"
|
||||
),
|
||||
))
|
||||
|
||||
if failure_cases and not prompts:
|
||||
worst = failure_cases[0]
|
||||
prompts.append(_patch_prompt(
|
||||
title="复查推荐失效样本的共同过滤条件",
|
||||
severity="medium",
|
||||
evidence=f"最差样本 {worst['name']} 收益{worst['pct_from_entry']}%,最大回撤{worst['max_drawdown_pct']}%。",
|
||||
target_files=["backend/app/engine/screener.py", "backend/app/llm/strategy_iteration.py"],
|
||||
prompt=(
|
||||
"请复查最近推荐失效样本的共同过滤条件,优先寻找可配置化的收紧项。"
|
||||
f"最差样本:{worst['name']}({worst['ts_code']}),策略{worst['strategy']},"
|
||||
f"信号{worst['entry_signal_type']},收益{worst['pct_from_entry']}%,"
|
||||
f"最大回撤{worst['max_drawdown_pct']}%。"
|
||||
"请不要凭单一样本大改策略,必须保留样本数门槛。"
|
||||
),
|
||||
))
|
||||
|
||||
return prompts[:4]
|
||||
|
||||
|
||||
def _patch_prompt(title: str, severity: str, evidence: str, target_files: list[str], prompt: str) -> dict:
|
||||
return {
|
||||
"title": title,
|
||||
"severity": severity,
|
||||
"evidence": evidence,
|
||||
"target_files": target_files,
|
||||
"prompt": prompt,
|
||||
"acceptance_criteria": [
|
||||
"python3 -m compileall backend/app 通过",
|
||||
"策略配置变更有版本记录且可回滚",
|
||||
"历史推荐和跟踪数据读取不受影响",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _derive_feedback_controls(report: dict) -> dict:
|
||||
suggestions = report.get("adjustment_suggestions", []) or []
|
||||
sample_size = int(report.get("sample_size") or 0)
|
||||
|
||||
controls = {
|
||||
"sample_size": sample_size,
|
||||
"enabled": sample_size >= 10,
|
||||
"buy_threshold_delta": 0,
|
||||
"max_position_pct_delta": 0,
|
||||
"actionable_limit_delta": 0,
|
||||
"watch_limit_delta": 0,
|
||||
"force_defensive": False,
|
||||
"notes": [],
|
||||
}
|
||||
|
||||
if sample_size < 10:
|
||||
controls["notes"].append("样本不足,暂不启用自动回写。")
|
||||
return controls
|
||||
|
||||
promote_count = 0
|
||||
tighten_count = 0
|
||||
reduce_count = 0
|
||||
|
||||
for item in suggestions[:6]:
|
||||
action = item.get("action")
|
||||
reason = item.get("reason", "")
|
||||
|
||||
if action == "promote":
|
||||
promote_count += 1
|
||||
controls["buy_threshold_delta"] -= 1
|
||||
controls["watch_limit_delta"] += 1
|
||||
elif action == "tighten":
|
||||
tighten_count += 1
|
||||
controls["buy_threshold_delta"] += 1
|
||||
controls["actionable_limit_delta"] -= 1
|
||||
controls["max_position_pct_delta"] -= 5
|
||||
elif action == "reduce":
|
||||
reduce_count += 1
|
||||
controls["buy_threshold_delta"] += 1
|
||||
controls["watch_limit_delta"] -= 1
|
||||
|
||||
if "弱势市场" in reason or item.get("target") == "defensive_watch":
|
||||
controls["force_defensive"] = True
|
||||
|
||||
controls["buy_threshold_delta"] = max(-2, min(3, controls["buy_threshold_delta"]))
|
||||
controls["max_position_pct_delta"] = max(-10, min(5, controls["max_position_pct_delta"]))
|
||||
controls["actionable_limit_delta"] = max(-2, min(1, controls["actionable_limit_delta"]))
|
||||
controls["watch_limit_delta"] = max(-2, min(2, controls["watch_limit_delta"]))
|
||||
|
||||
if controls["force_defensive"]:
|
||||
controls["notes"].append("最近弱市亏损样本偏多,优先启用防守约束。")
|
||||
elif tighten_count > promote_count:
|
||||
controls["notes"].append("最近失效样本偏多,整体建议略收紧。")
|
||||
elif promote_count > 0 and reduce_count == 0:
|
||||
controls["notes"].append("最近有效样本改善,可适度放宽观察与出手空间。")
|
||||
else:
|
||||
controls["notes"].append("最近样本无明显单边倾向,仅做轻微校正。")
|
||||
|
||||
return controls
|
||||
|
||||
|
||||
async def _generate_ai_iteration(rule_report: dict, rows: list[dict]) -> str:
|
||||
from app.llm.client import chat_completion
|
||||
from app.llm.prompts import STRATEGY_ITERATION_PROMPT
|
||||
from app.llm.strategy_config import get_prompt_content
|
||||
|
||||
sample = [
|
||||
{
|
||||
"name": r.get("name"),
|
||||
"strategy": r.get("strategy"),
|
||||
"signal": r.get("entry_signal_type"),
|
||||
"return": r.get("pct_from_entry"),
|
||||
"max_return": r.get("max_return_pct"),
|
||||
"drawdown": r.get("max_drawdown_pct"),
|
||||
"reason": r.get("close_reason"),
|
||||
"market_temp": r.get("market_temp_score"),
|
||||
"position_score": r.get("position_score"),
|
||||
}
|
||||
for r in rows[:20]
|
||||
]
|
||||
|
||||
prompt = await get_prompt_content("strategy_iteration", STRATEGY_ITERATION_PROMPT)
|
||||
user_msg = f"""{prompt}
|
||||
|
||||
规则复盘:
|
||||
{json.dumps(rule_report, ensure_ascii=False)}
|
||||
|
||||
样本:
|
||||
{json.dumps(sample, ensure_ascii=False)}
|
||||
"""
|
||||
|
||||
resp = await chat_completion([
|
||||
{"role": "system", "content": "你是一位A股策略复盘研究员,负责基于推荐结果提出保守、可验证的策略迭代建议。"},
|
||||
{"role": "user", "content": user_msg},
|
||||
])
|
||||
return resp.content.strip() if resp and resp.content else ""
|
||||
|
||||
|
||||
def _avg(values: list) -> float:
|
||||
clean = [float(v) for v in values if v is not None]
|
||||
if not clean:
|
||||
return 0
|
||||
return round(sum(clean) / len(clean), 2)
|
||||
@ -1,11 +1,9 @@
|
||||
"""动态策略选择器
|
||||
|
||||
在固定筛选引擎前增加一层“先选打法,再选股票”的策略决策。
|
||||
生产筛选只使用规则和策略配置,保证同一份行情输入得到稳定输出。
|
||||
LLM 只能用于离线复盘、配置建议或解释,不参与盘中策略换挡。
|
||||
根据市场温度和板块状态,纯规则选择当日策略 profile。
|
||||
不再使用 LLM 或数据库配置覆盖。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from pydantic import BaseModel
|
||||
@ -46,6 +44,23 @@ def get_strategy_profile_by_id(strategy_id: str) -> StrategyProfile:
|
||||
watch_cap = max(0, settings.watch_limit)
|
||||
|
||||
profiles = {
|
||||
"pre_market_ambush": StrategyProfile(
|
||||
strategy_id="pre_market_ambush",
|
||||
name="盘前埋伏",
|
||||
description="盘前选出缩量整理到位、回踩支撑、尚未启动的埋伏标的。",
|
||||
entry_signal_priority=["launch", "pullback", "breakout_confirm", "reversal", "breakout"],
|
||||
score_weights={"catalyst": 0.25, "theme_money": 0.22, "stock_money": 0.18, "emotion_role": 0.10, "timing": 0.25},
|
||||
min_score=55,
|
||||
buy_threshold=58,
|
||||
max_position_pct=25,
|
||||
allow_trading=True,
|
||||
actionable_limit=min(3, actionable_cap),
|
||||
watch_limit=min(5, watch_cap),
|
||||
target_focus_sectors=3,
|
||||
market_stance="埋伏待发",
|
||||
decision_note="盘前选票重点看整理充分度和催化预期,不追已启动标的。",
|
||||
notes=["优先缩量整理到位+催化预期", "回踩支撑位附近的主线成分优先"],
|
||||
),
|
||||
"breakout_attack": StrategyProfile(
|
||||
strategy_id="breakout_attack",
|
||||
name="主线突破",
|
||||
@ -122,11 +137,12 @@ async def select_strategy_profile(
|
||||
market_temp: MarketTemperature | None,
|
||||
hot_sectors: list[SectorInfo],
|
||||
intraday: bool,
|
||||
scan_session: str = "",
|
||||
) -> StrategyProfile:
|
||||
from app.llm.strategy_config import load_active_strategy_profile
|
||||
|
||||
profile = _select_rule_profile(market_temp, hot_sectors, intraday)
|
||||
return await load_active_strategy_profile(profile)
|
||||
"""纯规则策略选择。盘前埋伏 session 走独立 profile。"""
|
||||
if scan_session == "pre_market_ambush" or scan_session == "pre_market":
|
||||
return get_strategy_profile_by_id("pre_market_ambush")
|
||||
return _select_rule_profile(market_temp, hot_sectors, intraday)
|
||||
|
||||
|
||||
def _select_rule_profile(
|
||||
@ -148,106 +164,3 @@ def _select_rule_profile(
|
||||
return get_strategy_profile_by_id("launch_probe")
|
||||
|
||||
return get_strategy_profile_by_id("defensive_watch")
|
||||
|
||||
|
||||
async def _apply_strategy_feedback(profile: StrategyProfile) -> StrategyProfile:
|
||||
from app.llm.strategy_iteration import build_strategy_feedback_controls
|
||||
|
||||
try:
|
||||
controls = await build_strategy_feedback_controls(limit=50)
|
||||
except Exception as e:
|
||||
logger.debug(f"策略反馈控制生成失败: {e}")
|
||||
return profile
|
||||
|
||||
if not controls.get("enabled"):
|
||||
return profile
|
||||
|
||||
updated = profile.model_copy(deep=True)
|
||||
updated.feedback_applied = True
|
||||
|
||||
if controls.get("force_defensive"):
|
||||
updated.allow_trading = False
|
||||
updated.actionable_limit = 0
|
||||
updated.watch_limit = min(updated.watch_limit, 3)
|
||||
updated.max_position_pct = min(updated.max_position_pct, 10)
|
||||
updated.market_stance = "防守观察"
|
||||
|
||||
updated.buy_threshold = max(updated.min_score, min(updated.buy_threshold + int(controls.get("buy_threshold_delta") or 0), 80))
|
||||
updated.max_position_pct = max(0, min(updated.max_position_pct + int(controls.get("max_position_pct_delta") or 0), 40))
|
||||
updated.actionable_limit = max(0, min(updated.actionable_limit + int(controls.get("actionable_limit_delta") or 0), settings.actionable_limit))
|
||||
updated.watch_limit = max(1, min(updated.watch_limit + int(controls.get("watch_limit_delta") or 0), settings.watch_limit))
|
||||
|
||||
notes = controls.get("notes") or []
|
||||
if notes:
|
||||
updated.feedback_notes = notes[:3]
|
||||
updated.notes.extend(notes[:2])
|
||||
updated.decision_note = notes[0]
|
||||
|
||||
updated.generated_by = f"{updated.generated_by}+feedback"
|
||||
return updated
|
||||
|
||||
|
||||
async def _select_llm_profile(
|
||||
market_temp: MarketTemperature | None,
|
||||
hot_sectors: list[SectorInfo],
|
||||
intraday: bool,
|
||||
fallback: StrategyProfile,
|
||||
) -> StrategyProfile | None:
|
||||
from app.llm.client import chat_completion
|
||||
|
||||
sector_text = "\n".join(
|
||||
f"- {s.sector_name}: 涨幅{s.pct_change}%, 热度{s.heat_score}, 阶段{s.stage}, 涨停{s.limit_up_count}"
|
||||
for s in hot_sectors[:5]
|
||||
) or "暂无板块数据"
|
||||
|
||||
user_msg = f"""你需要为今日A股环境选择一个短线策略模板。
|
||||
|
||||
市场温度: {market_temp.temperature if market_temp else 0}
|
||||
上涨家数: {market_temp.up_count if market_temp else 0}
|
||||
下跌家数: {market_temp.down_count if market_temp else 0}
|
||||
涨停数: {market_temp.limit_up_count if market_temp else 0}
|
||||
炸板率: {market_temp.broken_rate if market_temp else 0}
|
||||
盘中模式: {'是' if intraday else '否'}
|
||||
|
||||
热门板块:
|
||||
{sector_text}
|
||||
|
||||
规则候选策略:
|
||||
- breakout_attack: 主线突破
|
||||
- pullback_rotation: 回踩轮动
|
||||
- launch_probe: 启动试错
|
||||
- defensive_watch: 防守观察
|
||||
|
||||
请输出 JSON,格式:
|
||||
{{
|
||||
"strategy_id": "上面四选一",
|
||||
"notes": ["两条以内理由"],
|
||||
"buy_threshold_delta": -3到3之间的整数
|
||||
}}
|
||||
"""
|
||||
|
||||
resp = await chat_completion([
|
||||
{"role": "system", "content": "你是一位A股短线策略研究员,只能在给定策略模板中选择,不要发明新策略。回复必须是 JSON。"},
|
||||
{"role": "user", "content": user_msg},
|
||||
])
|
||||
if not resp or not resp.content:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(resp.content)
|
||||
strategy_id = data.get("strategy_id")
|
||||
if strategy_id not in {"breakout_attack", "pullback_rotation", "launch_probe", "defensive_watch"}:
|
||||
return None
|
||||
selected = _select_rule_profile(market_temp, hot_sectors, intraday)
|
||||
if selected.strategy_id != strategy_id:
|
||||
selected = get_strategy_profile_by_id(strategy_id)
|
||||
|
||||
delta = int(data.get("buy_threshold_delta", 0))
|
||||
delta = max(-3, min(3, delta))
|
||||
selected.buy_threshold += delta
|
||||
selected.notes.extend(data.get("notes", [])[:2])
|
||||
selected.generated_by = "rules+llm"
|
||||
return selected
|
||||
except Exception as e:
|
||||
logger.debug(f"LLM 策略选择解析失败: {e}")
|
||||
return fallback
|
||||
|
||||
@ -74,23 +74,28 @@ def _clean_for_json(obj):
|
||||
|
||||
|
||||
async def _get_strategy_board() -> str:
|
||||
from app.llm.strategy_board import build_strategy_board
|
||||
"""返回当日市场概况 + 策略状态,替代已删除的 strategy_board 模块。"""
|
||||
from app.engine.recommender import get_latest_recommendations
|
||||
|
||||
result = await get_latest_recommendations()
|
||||
mt = result.get("market_temp")
|
||||
recs = result.get("recommendations", [])
|
||||
strategy = result.get("strategy_profile") or {}
|
||||
|
||||
actionable = [r for r in recs if r.action_plan == "可操作"]
|
||||
watch = [r for r in recs if r.action_plan == "重点关注"]
|
||||
|
||||
board = await build_strategy_board(include_llm=False)
|
||||
payload = {
|
||||
"trade_date": board.get("trade_date", ""),
|
||||
"market_regime": board.get("market_regime", ""),
|
||||
"risk_level": board.get("risk_level", ""),
|
||||
"action_bias": board.get("action_bias", ""),
|
||||
"position_suggestion": board.get("position_suggestion", ""),
|
||||
"summary": board.get("summary", ""),
|
||||
"recommended_mode": board.get("recommended_mode", ""),
|
||||
"watch_sectors": board.get("watch_sectors", [])[:5],
|
||||
"strategy_focus": board.get("strategy_focus", [])[:4],
|
||||
"avoid_rules": board.get("avoid_rules", [])[:4],
|
||||
"iteration_notes": board.get("iteration_notes", [])[:3],
|
||||
"metrics": board.get("metrics", {}),
|
||||
"generated_by": board.get("generated_by", "rules"),
|
||||
"market_temperature": mt.temperature if mt else 0,
|
||||
"up_count": mt.up_count if mt else 0,
|
||||
"down_count": mt.down_count if mt else 0,
|
||||
"limit_up_count": mt.limit_up_count if mt else 0,
|
||||
"strategy_name": strategy.get("name", "未知"),
|
||||
"market_stance": strategy.get("market_stance", ""),
|
||||
"decision_note": strategy.get("decision_note", ""),
|
||||
"actionable_count": len(actionable),
|
||||
"watch_count": len(watch),
|
||||
"total_recommendations": len(recs),
|
||||
}
|
||||
return json.dumps(_clean_for_json(payload), ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ from app.config import settings
|
||||
from app.db.error_logger import PersistentErrorLogHandler, log_error
|
||||
from app.db.database import init_db
|
||||
from app.engine.scheduler import start_scheduler, stop_scheduler
|
||||
from app.api import market, sectors, recommendations, stocks, watchlists, websocket, chat, auth, debug, catalysts
|
||||
from app.api import market, sectors, recommendations, stocks, watchlists, websocket, chat, auth, catalysts
|
||||
|
||||
def configure_logging() -> None:
|
||||
logging.basicConfig(
|
||||
@ -103,9 +103,6 @@ async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
logger.info("数据库初始化完成")
|
||||
await ensure_admin_exists()
|
||||
from app.llm.strategy_config import ensure_default_configs
|
||||
await ensure_default_configs()
|
||||
logger.info("策略配置中心初始化完成")
|
||||
start_scheduler()
|
||||
logger.info("调度器已启动")
|
||||
yield
|
||||
@ -147,7 +144,6 @@ app.include_router(stocks.router)
|
||||
app.include_router(watchlists.router)
|
||||
app.include_router(chat.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(debug.router)
|
||||
app.include_router(catalysts.router)
|
||||
|
||||
# WebSocket
|
||||
|
||||
@ -1,183 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { getDataSourceHealthAPI, type DataSourceHealthResult } from "@/lib/api";
|
||||
|
||||
export default function DataHealthPage() {
|
||||
const { user } = useAuth();
|
||||
const [days, setDays] = useState(7);
|
||||
const [data, setData] = useState<DataSourceHealthResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(await getDataSourceHealthAPI(days));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [days]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role === "admin") loadData();
|
||||
}, [user, loadData]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const sources = data?.sources || [];
|
||||
return {
|
||||
total: sources.length,
|
||||
errors: sources.filter((item) => item.status === "error").length,
|
||||
warnings: sources.filter((item) => item.status === "warning").length,
|
||||
ok: sources.filter((item) => item.status === "ok").length,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
if (user?.role !== "admin") {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6">
|
||||
<p className="text-sm text-text-muted">需要管理员权限</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||
<header className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-text-muted">Data Health</div>
|
||||
<h1 className="mt-2 text-2xl font-bold tracking-tight text-text-primary">数据源健康</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-text-secondary">
|
||||
东方财富、腾讯、Tushare、AKShare 等数据源的错误聚合和本地数据新鲜度。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={days}
|
||||
onChange={(event) => setDays(Number(event.target.value))}
|
||||
className="rounded-xl border border-border-default bg-surface-2 px-3 py-2 text-xs text-text-primary outline-none focus:ring-1 focus:ring-amber-500/30"
|
||||
>
|
||||
<option value={1}>1天</option>
|
||||
<option value={3}>3天</option>
|
||||
<option value={7}>7天</option>
|
||||
<option value={14}>14天</option>
|
||||
<option value={30}>30天</option>
|
||||
</select>
|
||||
<button onClick={loadData} className="rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-xs font-medium text-text-secondary transition-all hover:bg-surface-4 hover:text-text-primary">
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<MetricCard label="数据源" value={summary.total} />
|
||||
<MetricCard label="正常" value={summary.ok} tone="ok" />
|
||||
<MetricCard label="警告" value={summary.warnings} tone="warning" />
|
||||
<MetricCard label="错误" value={summary.errors} tone="error" />
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-5 xl:grid-cols-[1fr_360px]">
|
||||
<div 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>
|
||||
<span className="text-[10px] text-text-muted">{data ? formatDateTime(data.generated_at) : "-"}</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="space-y-3 p-4">
|
||||
{[1, 2, 3, 4].map((item) => <div key={item} className="h-20 animate-shimmer rounded-xl bg-surface-2" />)}
|
||||
</div>
|
||||
) : !data?.sources.length ? (
|
||||
<div className="p-10 text-center text-sm text-text-muted">暂无数据源记录</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{data.sources.map((item) => (
|
||||
<article key={item.source} className="grid gap-3 px-4 py-4 lg:grid-cols-[150px_110px_120px_1fr_140px] lg:items-center">
|
||||
<div className="text-sm font-semibold text-text-primary">{sourceLabel(item.source)}</div>
|
||||
<StatusPill status={item.status} />
|
||||
<div className="font-mono text-xs tabular-nums text-text-secondary">
|
||||
{item.error_count}错 / {item.warning_count}警
|
||||
</div>
|
||||
<div className="min-w-0 text-xs leading-5 text-text-muted line-clamp-2">
|
||||
{item.last_error || "最近没有记录到异常"}
|
||||
</div>
|
||||
<div className="font-mono text-[10px] tabular-nums text-text-muted lg:text-right">
|
||||
{formatDateTime(item.last_seen_at)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="glass-card-static p-4 self-start">
|
||||
<h2 className="text-sm font-semibold text-text-primary">本地数据新鲜度</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
{Object.entries(data?.freshness || {}).map(([key, value]) => (
|
||||
<div key={key} className="rounded-xl border border-border-subtle bg-surface-1/70 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-semibold text-text-primary">{freshnessLabel(key)}</span>
|
||||
<span className="font-mono text-[10px] text-text-muted">{formatDateTime(value.created_at || "")}</span>
|
||||
</div>
|
||||
{"trade_date" in value ? (
|
||||
<div className="mt-2 text-xs text-text-secondary">交易日 {value.trade_date || "-"}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{!data && <div className="rounded-xl border border-border-subtle bg-surface-1/70 p-5 text-sm text-text-muted">读取中...</div>}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, tone = "default" }: { label: string; value: number; tone?: "default" | "ok" | "warning" | "error" }) {
|
||||
const color = tone === "ok" ? "text-emerald-400" : tone === "warning" ? "text-amber-400" : tone === "error" ? "text-red-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-2xl font-bold tabular-nums ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: string }) {
|
||||
const className = status === "ok"
|
||||
? "border-emerald-500/15 bg-emerald-500/[0.06] text-emerald-400"
|
||||
: status === "warning"
|
||||
? "border-amber-500/15 bg-amber-500/[0.07] text-amber-400"
|
||||
: "border-red-500/15 bg-red-500/[0.07] text-red-400";
|
||||
const label = status === "ok" ? "正常" : status === "warning" ? "警告" : "异常";
|
||||
return <span className={`w-fit rounded-md border px-2 py-0.5 text-[10px] font-medium ${className}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function sourceLabel(source: string) {
|
||||
const map: Record<string, string> = {
|
||||
eastmoney: "东方财富",
|
||||
tencent: "腾讯行情",
|
||||
tushare: "Tushare",
|
||||
akshare: "AKShare",
|
||||
sina: "新浪行情",
|
||||
news: "新闻管道",
|
||||
tushare_news: "Tushare新闻",
|
||||
};
|
||||
return map[source] || source;
|
||||
}
|
||||
|
||||
function freshnessLabel(key: string) {
|
||||
const map: Record<string, string> = {
|
||||
market_temperature: "市场温度",
|
||||
sector_heat: "板块热度",
|
||||
recommendations: "推荐结论",
|
||||
news_items: "新闻原文",
|
||||
catalysts: "舆情催化",
|
||||
};
|
||||
return map[key] || key;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
|
||||
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
@ -1,551 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { fetchAPI, type DiagnosisResult, type StockThesisResponse } from "@/lib/api";
|
||||
import { markdownToHtml } from "@/lib/markdown";
|
||||
import { ErrorBoundary } from "@/components/error-boundary";
|
||||
|
||||
interface SearchResult {
|
||||
ts_code: string;
|
||||
name: string;
|
||||
industry: string;
|
||||
}
|
||||
|
||||
interface DiagnoseHistoryItem {
|
||||
ts_code: string;
|
||||
name: string;
|
||||
diagnosis_mode?: string;
|
||||
diagnosis?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export default function DiagnosePage() {
|
||||
const { theme } = useTheme();
|
||||
const searchParams = useSearchParams();
|
||||
const codeParam = searchParams.get("code");
|
||||
const [input, setInput] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState("");
|
||||
const [result, setResult] = useState<DiagnosisResult | null>(null);
|
||||
const [cachedResult, setCachedResult] = useState<string | null>(null);
|
||||
const [history, setHistory] = useState<{ ts_code: string; name: string }[]>([]);
|
||||
const [diagnoseHistory, setDiagnoseHistory] = useState<DiagnoseHistoryItem[]>([]);
|
||||
const [thesis, setThesis] = useState<StockThesisResponse | null>(null);
|
||||
const [diagnoseMode, setDiagnoseMode] = useState<"entry" | "holding" | "review" | "tracking">("entry");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
setShowSearch(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!codeParam) return;
|
||||
setInput(codeParam);
|
||||
runDiagnosis(codeParam);
|
||||
}, [codeParam]);
|
||||
|
||||
const searchStock = useCallback(async (keyword: string) => {
|
||||
if (!keyword.trim() || keyword.length < 1) {
|
||||
setSearchResults([]);
|
||||
setShowSearch(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await fetchAPI<SearchResult[]>(`/api/stocks/search?keyword=${encodeURIComponent(keyword)}`);
|
||||
setSearchResults(data);
|
||||
setShowSearch(data.length > 0);
|
||||
} catch {
|
||||
setSearchResults([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
setInput(value);
|
||||
clearTimeout(searchTimer.current);
|
||||
searchTimer.current = setTimeout(() => searchStock(value), 300);
|
||||
};
|
||||
|
||||
const selectStock = (stock: SearchResult) => {
|
||||
setInput(`${stock.name} (${stock.ts_code})`);
|
||||
setShowSearch(false);
|
||||
setSearchResults([]);
|
||||
};
|
||||
|
||||
const runDiagnosis = async (tsCode?: string) => {
|
||||
let code = tsCode;
|
||||
if (!code) {
|
||||
const match = input.match(/\((\d{6}\.[A-Z]{2})\)/);
|
||||
if (match) {
|
||||
code = match[1];
|
||||
} else if (/^\d{6}$/.test(input.trim())) {
|
||||
code = `${input.trim()}.SH`;
|
||||
} else if (/^\d{6}\.[A-Z]{2}$/.test(input.trim())) {
|
||||
code = input.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (!code) return;
|
||||
|
||||
setLoading(true);
|
||||
setStreamingContent("");
|
||||
setResult(null);
|
||||
setCachedResult(null);
|
||||
|
||||
fetchAPI<StockThesisResponse>(`/api/stocks/${code}/thesis`).then(setThesis).catch(() => setThesis(null));
|
||||
fetchAPI<DiagnoseHistoryItem[]>(`/api/stocks/${code}/diagnose/history`).then(setDiagnoseHistory).catch(() => setDiagnoseHistory([]));
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`/api/stocks/${code}/diagnose?mode=${encodeURIComponent(diagnoseMode)}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
if (!res.body) throw new Error("No response body");
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let fullContent = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6).trim();
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
if (parsed.cached && parsed.diagnosis) {
|
||||
setCachedResult(parsed.diagnosis);
|
||||
fullContent = parsed.diagnosis;
|
||||
} else if (parsed.token) {
|
||||
fullContent += parsed.token;
|
||||
setStreamingContent(fullContent);
|
||||
} else if (parsed.error) {
|
||||
setResult({ status: "error", message: parsed.error });
|
||||
} else if (parsed.done) {
|
||||
if (fullContent) {
|
||||
setResult({ status: "ok", ts_code: parsed.ts_code || code, diagnosis: fullContent });
|
||||
const name = input.split(" (")[0] || parsed.ts_code || code;
|
||||
setHistory((prev) => {
|
||||
const filtered = prev.filter((h) => h.ts_code !== (parsed.ts_code || code));
|
||||
return [{ ts_code: parsed.ts_code || code, name }, ...filtered].slice(0, 10);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setResult({ status: "error", message: "诊断失败,请检查股票代码后重试" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !showSearch) {
|
||||
e.preventDefault();
|
||||
runDiagnosis();
|
||||
}
|
||||
};
|
||||
|
||||
const displayContent = cachedResult || result?.diagnosis || streamingContent;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
||||
{/* Header */}
|
||||
<div className="mb-6 animate-fade-in-up">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-8 h-8 rounded-xl bg-gradient-to-br from-amber-500/25 to-amber-600/15 flex items-center justify-center border border-amber-500/15">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-amber-400">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold tracking-tight">个股诊断</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.2fr)_320px] gap-5">
|
||||
<section className="space-y-5">
|
||||
<div className="glass-card-static p-4 mb-1 animate-fade-in-up">
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold mb-2">诊断工作台</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_auto] gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{[
|
||||
{ key: "entry", label: "建仓前诊断" },
|
||||
{ key: "holding", label: "持仓复核" },
|
||||
{ key: "review", label: "回撤复盘" },
|
||||
{ key: "tracking", label: "继续跟踪" },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setDiagnoseMode(item.key as typeof diagnoseMode)}
|
||||
className={`text-xs px-3 py-1.5 rounded-lg transition-all ${
|
||||
diagnoseMode === item.key
|
||||
? "bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/15"
|
||||
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div ref={wrapperRef} className="relative z-30">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => searchResults.length > 0 && setShowSearch(true)}
|
||||
placeholder="输入股票名称或代码,如 600683 或 京投发展"
|
||||
className="w-full bg-surface-2 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-1 focus:ring-amber-400/30 placeholder-text-muted/40 border border-border-subtle transition-all duration-200"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => runDiagnosis()}
|
||||
disabled={loading || !input.trim()}
|
||||
className="px-6 py-3 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl text-sm font-medium hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-30 transition-all duration-200 border border-amber-500/10 shrink-0"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="w-3.5 h-3.5 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
|
||||
分析中
|
||||
</span>
|
||||
) : (
|
||||
"开始诊断"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{showSearch && searchResults.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-bg-secondary border border-border-subtle rounded-xl shadow-lg z-20 overflow-hidden">
|
||||
{searchResults.map((stock) => (
|
||||
<button
|
||||
key={stock.ts_code}
|
||||
onClick={() => selectStock(stock)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 text-sm hover:bg-surface-3 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-primary font-medium">{stock.name}</span>
|
||||
<span className="text-text-muted text-xs">{stock.ts_code}</span>
|
||||
</div>
|
||||
{stock.industry && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-2 text-text-muted">
|
||||
{stock.industry}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{thesis ? (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">诊断依据</div>
|
||||
<div className="text-sm text-text-secondary mt-2 leading-relaxed">
|
||||
已读取该股最近推荐、跟踪和诊断记录。
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 mt-3">
|
||||
{(thesis.decision_points ?? []).slice(0, 3).map((point) => (
|
||||
<DiagnosisSummaryCard key={point.label} label={point.label} value={point.value} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4 text-sm text-text-muted">
|
||||
输入股票后,这里会展示推荐归档、跟踪状态和最近诊断上下文。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Streaming / Loading State */}
|
||||
{loading && !displayContent && (
|
||||
<div className="glass-card-static p-10 text-center animate-fade-in-up">
|
||||
<div className="w-8 h-8 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin mx-auto mb-3" />
|
||||
<div className="text-sm text-text-secondary mb-1">正在分析中...</div>
|
||||
<div className="text-xs text-text-muted/50">收集行情、板块和推荐归档,生成本次会诊结论</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming content */}
|
||||
{loading && displayContent && (
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="w-3 h-3 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
|
||||
<span className="text-xs text-text-muted">正在分析...</span>
|
||||
</div>
|
||||
<div className="text-sm text-text-secondary leading-relaxed whitespace-pre-line">
|
||||
{displayContent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final Result */}
|
||||
{!loading && displayContent && (
|
||||
<div className="animate-fade-in-up space-y-4">
|
||||
<div className="glass-card-static p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold mb-1">结构化结论</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => runDiagnosis(result?.ts_code || codeParam || undefined)}
|
||||
className="text-xs text-text-muted hover:text-amber-400 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 4v6h6" />
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||
</svg>
|
||||
重新诊断
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-2 text-sm">
|
||||
<DiagnosisSummaryCard label="诊断模式" value={getModeLabel(diagnoseMode)} />
|
||||
<DiagnosisSummaryCard label="当前结论" value={buildDiagnosisConclusion(thesis, loading)} />
|
||||
<DiagnosisSummaryCard label="所属板块" value={thesis?.recommendation?.sector || "暂无归档"} />
|
||||
<DiagnosisSummaryCard label="风险线索" value={buildDiagnosisRisk(thesis, loading)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass-card-static p-4">
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold mb-3">会诊摘要</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<DiagnosisSummaryCard label="执行建议" value={buildDiagnosisAction(thesis, diagnoseMode)} />
|
||||
<DiagnosisSummaryCard label="下一步" value={buildDiagnosisNextStep(thesis, diagnoseMode)} />
|
||||
<DiagnosisSummaryCard label="触发关注" value={thesis?.recommendation?.trigger_condition || "等待正文补充"} />
|
||||
<DiagnosisSummaryCard label="失效边界" value={thesis?.recommendation?.invalidation_condition || "等待正文补充"} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass-card-static p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-text-primary">{result?.ts_code || codeParam}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-emerald-500/10 text-emerald-400 border border-emerald-500/15">
|
||||
{cachedResult ? "历史结论" : "分析完成"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm text-text-secondary leading-relaxed prose prose-sm max-w-none [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-amber-400 [&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:first:mt-0 [&_h3]:text-xs [&_h3]:font-semibold [&_h3]:text-text-primary [&_h3]:mt-3 [&_h3]:mb-1.5 [&_p]:text-text-secondary [&_p]:mb-2.5 [&_p]:leading-relaxed [&_ul]:text-text-secondary [&_ul]:mb-2.5 [&_li]:mb-1 [&_strong]:text-text-primary ${theme !== "light" ? "prose-invert" : ""}`}
|
||||
dangerouslySetInnerHTML={{ __html: markdownToHtml(displayContent) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{result?.status === "error" && !loading && !displayContent && (
|
||||
<div className="glass-card-static p-8 text-center animate-fade-in-up">
|
||||
<div className="text-sm text-red-400 mb-2">诊断失败</div>
|
||||
<div className="text-xs text-text-muted">{result.message || "未知错误"}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!result && !loading && !displayContent && history.length === 0 && (
|
||||
<div className="glass-card-static p-10 text-center animate-fade-in-up">
|
||||
<div className="w-12 h-12 rounded-2xl bg-surface-2 flex items-center justify-center mx-auto mb-4">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-text-muted/40">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-sm text-text-muted mb-2">输入股票代码开始诊断</div>
|
||||
<div className="text-xs text-text-muted/50 mb-4">
|
||||
支持股票代码(如 600683)或名称(如 京投发展)
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{["贵州茅台", "宁德时代", "比亚迪"].map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => {
|
||||
setInput(name);
|
||||
searchStock(name);
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 bg-surface-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-all border border-border-subtle"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="space-y-5">
|
||||
{thesis?.latest_tracking ? (
|
||||
<div className="glass-card-static p-4 animate-fade-in-up">
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">最新跟踪</div>
|
||||
<div className="text-sm text-text-secondary mt-2 leading-relaxed">
|
||||
{thesis.latest_tracking.review_note || "暂无备注"}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{diagnoseHistory.length > 0 ? (
|
||||
<div className="glass-card-static p-4 animate-fade-in-up">
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-text-muted font-semibold">历史会诊</div>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{diagnoseHistory.slice(0, 6).map((item, index) => (
|
||||
<span key={`${item.ts_code}-${index}`} className="text-[11px] px-2 py-1 rounded-lg bg-surface-2 border border-border-subtle text-text-secondary">
|
||||
{getModeLabel((item.diagnosis_mode as "entry" | "holding" | "review" | "tracking") || "entry")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{history.length > 0 && !displayContent ? (
|
||||
<div className="glass-card-static p-4 animate-fade-in-up">
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-text-muted font-semibold">最近诊断</div>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{history.map((h) => (
|
||||
<button
|
||||
key={h.ts_code}
|
||||
onClick={() => {
|
||||
setInput(`${h.name} (${h.ts_code})`);
|
||||
runDiagnosis(h.ts_code);
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 bg-surface-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-all border border-border-subtle"
|
||||
>
|
||||
{h.name}
|
||||
<span className="text-text-muted/50 ml-1">{h.ts_code}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagnosisSummaryCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-surface-1 border border-border-subtle px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/60">{label}</div>
|
||||
<div className="text-sm text-text-secondary mt-1 leading-relaxed">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getModeLabel(mode: "entry" | "holding" | "review" | "tracking") {
|
||||
const map = {
|
||||
entry: "建仓前诊断",
|
||||
holding: "持仓复核",
|
||||
review: "回撤复盘",
|
||||
tracking: "继续跟踪",
|
||||
};
|
||||
return map[mode];
|
||||
}
|
||||
|
||||
function getModeDescription(mode: "entry" | "holding" | "review" | "tracking") {
|
||||
const map = {
|
||||
entry: "重点看能否进场、触发条件和失效条件。",
|
||||
holding: "重点看持仓逻辑是否还成立、需不需要减仓或退出。",
|
||||
review: "重点看为何回撤、问题出在个股还是环境。",
|
||||
tracking: "重点看是否继续保留在观察池和推荐池。",
|
||||
};
|
||||
return map[mode];
|
||||
}
|
||||
|
||||
function buildDiagnosisConclusion(thesis: StockThesisResponse | null, loading: boolean) {
|
||||
if (thesis?.recommendation?.action_plan) {
|
||||
return thesis.recommendation.action_plan;
|
||||
}
|
||||
if (loading) {
|
||||
return "正在生成会诊";
|
||||
}
|
||||
if (thesis?.has_recommendation === false) {
|
||||
return "暂无推荐归档,以本次诊断为准";
|
||||
}
|
||||
return "暂无明确结论";
|
||||
}
|
||||
|
||||
function buildDiagnosisRisk(thesis: StockThesisResponse | null, loading: boolean) {
|
||||
if (thesis?.recommendation?.risk_note) {
|
||||
return thesis.recommendation.risk_note;
|
||||
}
|
||||
if (thesis?.latest_tracking?.review_note) {
|
||||
return thesis.latest_tracking.review_note;
|
||||
}
|
||||
if (loading) {
|
||||
return "正在补充风险判断";
|
||||
}
|
||||
return "当前没有明确风险备注";
|
||||
}
|
||||
|
||||
function buildDiagnosisAction(
|
||||
thesis: StockThesisResponse | null,
|
||||
mode: "entry" | "holding" | "review" | "tracking",
|
||||
) {
|
||||
if (thesis?.recommendation?.action_plan === "可操作") {
|
||||
return "仅在触发条件成立时执行,不提前交易。";
|
||||
}
|
||||
if (thesis?.recommendation?.action_plan === "重点关注") {
|
||||
return "继续跟踪板块和个股强度,等确认后再做动作。";
|
||||
}
|
||||
if (mode === "holding") {
|
||||
return "优先核对持仓逻辑、失效条件和风险暴露。";
|
||||
}
|
||||
if (mode === "review") {
|
||||
return "先定位问题出在市场环境、板块节奏还是个股执行。";
|
||||
}
|
||||
return "当前以观察和补充证据为主。";
|
||||
}
|
||||
|
||||
function buildDiagnosisNextStep(
|
||||
thesis: StockThesisResponse | null,
|
||||
mode: "entry" | "holding" | "review" | "tracking",
|
||||
) {
|
||||
if (thesis?.recommendation?.trigger_condition) {
|
||||
return `重点盯住:${thesis.recommendation.trigger_condition}`;
|
||||
}
|
||||
if (mode === "tracking") {
|
||||
return "继续观察是否进入可操作或重点关注状态。";
|
||||
}
|
||||
if (mode === "holding") {
|
||||
return "检查是否需要减仓、止损或继续持有。";
|
||||
}
|
||||
return "结合下一个交易时段的量价和板块变化再判断。";
|
||||
}
|
||||
@ -1,686 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
clearErrorLogsAPI,
|
||||
getErrorLogsAPI,
|
||||
getResearchObservationsAPI,
|
||||
getScanLogsAPI,
|
||||
getScanSessionsAPI,
|
||||
getSystemStatusAPI,
|
||||
type ErrorLog,
|
||||
type ResearchObservation,
|
||||
type ScanProcessLog,
|
||||
type ScanSessionSummary,
|
||||
type SystemStatus,
|
||||
} from "@/lib/api";
|
||||
|
||||
type OpsTab = "funnel" | "errors";
|
||||
|
||||
const STAGE_ORDER = [
|
||||
"market_temperature",
|
||||
"theme_selection",
|
||||
"strategy_profile",
|
||||
"candidate_recall",
|
||||
"realtime_quote",
|
||||
"rule_scoring",
|
||||
"final_filter",
|
||||
];
|
||||
|
||||
export default function OpsLogsPage() {
|
||||
const { user } = useAuth();
|
||||
const [tab, setTab] = useState<OpsTab>("errors");
|
||||
const [days, setDays] = useState(7);
|
||||
const [sessions, setSessions] = useState<ScanSessionSummary[]>([]);
|
||||
const [selectedSession, setSelectedSession] = useState("");
|
||||
const [scanLogs, setScanLogs] = useState<ScanProcessLog[]>([]);
|
||||
const [observations, setObservations] = useState<ResearchObservation[]>([]);
|
||||
const [reasonCounts, setReasonCounts] = useState<Record<string, number>>({});
|
||||
const [scanLoading, setScanLoading] = useState(true);
|
||||
|
||||
const [errors, setErrors] = useState<ErrorLog[]>([]);
|
||||
const [errorsTotal, setErrorsTotal] = useState(0);
|
||||
const [sources, setSources] = useState<string[]>([]);
|
||||
const [levels, setLevels] = useState<string[]>([]);
|
||||
const [sourceCounts, setSourceCounts] = useState<Record<string, number>>({});
|
||||
const [levelCounts, setLevelCounts] = useState<Record<string, number>>({});
|
||||
const [source, setSource] = useState("");
|
||||
const [level, setLevel] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [errorsLoading, setErrorsLoading] = useState(false);
|
||||
const [expandedErrorId, setExpandedErrorId] = useState<number | null>(null);
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null);
|
||||
|
||||
const latestSession = sessions[0];
|
||||
const activeSession = sessions.find((item) => item.scan_session === selectedSession) || latestSession;
|
||||
const sortedLogs = useMemo(
|
||||
() => [...scanLogs].sort((a, b) => STAGE_ORDER.indexOf(a.stage) - STAGE_ORDER.indexOf(b.stage)),
|
||||
[scanLogs],
|
||||
);
|
||||
const maxCount = Math.max(...sortedLogs.map((item) => Math.max(item.input_count, item.output_count)), 1);
|
||||
const finalLog = sortedLogs.find((item) => item.stage === "final_filter");
|
||||
const latestError = errors[0];
|
||||
|
||||
const fetchScanData = useCallback(async (session?: string) => {
|
||||
setScanLoading(true);
|
||||
try {
|
||||
const sessionData = await getScanSessionsAPI(days, 30);
|
||||
setSessions(sessionData.sessions);
|
||||
const nextSession = session || sessionData.sessions[0]?.scan_session || "";
|
||||
setSelectedSession(nextSession);
|
||||
const logData = await getScanLogsAPI(nextSession || undefined, days, 140);
|
||||
setScanLogs(logData.logs);
|
||||
const observationData = await getResearchObservationsAPI(nextSession || undefined, days, 80);
|
||||
setObservations(observationData.observations);
|
||||
setReasonCounts(observationData.reason_counts);
|
||||
} catch {
|
||||
setScanLogs([]);
|
||||
setObservations([]);
|
||||
setReasonCounts({});
|
||||
} finally {
|
||||
setScanLoading(false);
|
||||
}
|
||||
}, [days]);
|
||||
|
||||
const fetchErrors = useCallback(async () => {
|
||||
setErrorsLoading(true);
|
||||
try {
|
||||
const result = await getErrorLogsAPI(120, source, level, days, query.trim());
|
||||
setErrors(result.errors);
|
||||
setErrorsTotal(result.total);
|
||||
setSources(result.sources);
|
||||
setLevels(result.levels);
|
||||
setSourceCounts(result.source_counts || {});
|
||||
setLevelCounts(result.level_counts || {});
|
||||
} catch {
|
||||
setErrors([]);
|
||||
setSourceCounts({});
|
||||
setLevelCounts({});
|
||||
} finally {
|
||||
setErrorsLoading(false);
|
||||
}
|
||||
}, [days, level, query, source]);
|
||||
|
||||
const fetchSystemStatus = useCallback(async () => {
|
||||
try {
|
||||
setSystemStatus(await getSystemStatusAPI());
|
||||
} catch {
|
||||
setSystemStatus(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role === "admin") {
|
||||
fetchScanData();
|
||||
fetchSystemStatus();
|
||||
}
|
||||
}, [user, days, fetchScanData, fetchSystemStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role === "admin" && tab === "errors") {
|
||||
fetchErrors();
|
||||
}
|
||||
}, [user, tab, fetchErrors]);
|
||||
|
||||
if (user?.role !== "admin") {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6">
|
||||
<p className="text-sm text-text-muted">需要管理员权限</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSelectSession(session: string) {
|
||||
setSelectedSession(session);
|
||||
setScanLoading(true);
|
||||
try {
|
||||
const result = await getScanLogsAPI(session, days, 140);
|
||||
setScanLogs(result.logs);
|
||||
const observationData = await getResearchObservationsAPI(session, days, 80);
|
||||
setObservations(observationData.observations);
|
||||
setReasonCounts(observationData.reason_counts);
|
||||
} finally {
|
||||
setScanLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearErrors() {
|
||||
const result = await clearErrorLogsAPI(30);
|
||||
await fetchErrors();
|
||||
await fetchSystemStatus();
|
||||
alert(`已清除 ${result.deleted} 条旧日志`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||
<header className="flex flex-col gap-4 animate-fade-in-up md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-text-muted">Ops Center</div>
|
||||
<h1 className="mt-2 text-2xl font-bold tracking-tight text-text-primary">运行日志</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-text-secondary">
|
||||
系统错误、接口异常、数据源失败和扫描漏斗集中在这里,方便快速定位异常来源。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={days}
|
||||
onChange={(event) => setDays(Number(event.target.value))}
|
||||
className="rounded-xl border border-border-default bg-surface-2 px-3 py-2 text-xs text-text-primary outline-none focus:ring-1 focus:ring-amber-500/30"
|
||||
>
|
||||
<option value={1}>1天</option>
|
||||
<option value={3}>3天</option>
|
||||
<option value={7}>7天</option>
|
||||
<option value={14}>14天</option>
|
||||
<option value={30}>30天</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => tab === "funnel" ? fetchScanData(selectedSession) : fetchErrors()}
|
||||
className="rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-xs font-medium text-text-secondary transition-all hover:bg-surface-4 hover:text-text-primary"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
||||
{[
|
||||
{ key: "errors", label: "系统错误" },
|
||||
{ key: "funnel", label: "筛选漏斗" },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setTab(item.key as OpsTab)}
|
||||
className={`rounded-xl border px-4 py-2 text-sm font-medium transition-all ${
|
||||
tab === item.key
|
||||
? "border-amber-500/15 bg-amber-500/[0.08] text-amber-400"
|
||||
: "border-transparent bg-surface-2 text-text-muted hover:bg-surface-3 hover:text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "funnel" ? (
|
||||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[280px_1fr]">
|
||||
<aside className="glass-card-static p-4 self-start">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-text-primary">扫描批次</h2>
|
||||
<span className="text-[10px] text-text-muted">{sessions.length}轮</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="rounded-xl border border-border-subtle bg-surface-1/70 p-5 text-sm text-text-muted">
|
||||
暂无筛选日志,下一次扫描后会自动出现。
|
||||
</div>
|
||||
) : sessions.map((item) => (
|
||||
<button
|
||||
key={item.scan_session}
|
||||
onClick={() => handleSelectSession(item.scan_session)}
|
||||
className={`w-full rounded-xl border p-3 text-left transition-all ${
|
||||
activeSession?.scan_session === item.scan_session
|
||||
? "border-amber-500/20 bg-amber-500/[0.06]"
|
||||
: "border-border-subtle bg-surface-1/70 hover:border-border-default hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs font-semibold text-text-primary">{shortSession(item.scan_session)}</span>
|
||||
<StatusPill status={item.status} />
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] text-text-muted">{formatDateTime(item.created_at)}</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
<TinyMetric label="关口" value={item.stage_count} />
|
||||
<TinyMetric label="保留" value={item.final_count} />
|
||||
<TinyMetric label="过滤" value={item.drop_count} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="space-y-5">
|
||||
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<SummaryCard label="当前批次" value={activeSession ? shortSession(activeSession.scan_session) : "-"} sub={activeSession ? formatDateTime(activeSession.created_at) : "等待扫描"} />
|
||||
<SummaryCard label="最终保留" value={finalLog?.output_count ?? activeSession?.final_count ?? 0} sub={finalLog?.summary || activeSession?.last_summary || "暂无结果"} tone="primary" />
|
||||
<SummaryCard label="最近错误" value={systemStatus?.recent_errors ?? 0} sub="过去24小时" tone={(systemStatus?.recent_errors ?? 0) > 0 ? "danger" : "muted"} />
|
||||
<SummaryCard label="扫描状态" value={systemStatus?.scan_running ? "扫描中" : "空闲"} sub={systemStatus?.scan_locked ? "任务锁占用" : "可接受下一轮任务"} tone={systemStatus?.scan_running ? "warning" : "muted"} />
|
||||
</section>
|
||||
|
||||
<section className="glass-card-static p-4 md:p-5">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary">筛选过程可视化</h2>
|
||||
<p className="mt-1 text-xs text-text-muted">输入、输出、过滤量和关键摘要按筛选顺序归档。</p>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono tabular-nums text-text-muted">{activeSession?.scan_mode || "-"}</span>
|
||||
</div>
|
||||
|
||||
{scanLoading ? (
|
||||
<div className="mt-5 space-y-3">
|
||||
{[1, 2, 3, 4].map((item) => <div key={item} className="h-20 animate-shimmer rounded-xl bg-surface-2" />)}
|
||||
</div>
|
||||
) : sortedLogs.length === 0 ? (
|
||||
<div className="mt-5 rounded-2xl border border-border-subtle bg-surface-1/70 p-10 text-center text-sm text-text-muted">
|
||||
暂无筛选过程日志
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 space-y-3">
|
||||
{sortedLogs.map((log, index) => (
|
||||
<FunnelStage key={log.id} log={log} index={index} maxCount={maxCount} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="glass-card-static p-4 md:p-5">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary">投研观察</h2>
|
||||
<p className="mt-1 text-xs text-text-muted">候选股的主题、资金、角色、入场信号和最终淘汰原因。</p>
|
||||
</div>
|
||||
<span className="text-[10px] text-text-muted">{observations.length} 条记录</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-[260px_1fr]">
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
|
||||
<div className="text-xs font-semibold text-text-primary">淘汰原因</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{Object.entries(reasonCounts).slice(0, 8).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>
|
||||
))}
|
||||
{Object.keys(reasonCounts).length === 0 ? (
|
||||
<div className="rounded-lg bg-surface-2 px-3 py-4 text-xs text-text-muted">暂无原因分布</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-2xl border border-border-subtle bg-surface-1/70">
|
||||
{observations.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-text-muted">暂无投研观察记录</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{observations.slice(0, 12).map((item) => (
|
||||
<ResearchRow key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
) : (
|
||||
<section className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<SummaryCard label="错误总数" value={errorsTotal} sub={`最近 ${days} 天`} tone={errorsTotal > 0 ? "danger" : "muted"} />
|
||||
<SummaryCard label="严重错误" value={levelCounts.critical ?? 0} sub="critical 级别" tone={(levelCounts.critical ?? 0) > 0 ? "danger" : "muted"} />
|
||||
<SummaryCard label="异常来源" value={Object.keys(sourceCounts).length} sub={topSourceLabel(sourceCounts)} tone="warning" />
|
||||
<SummaryCard label="最近一次" value={latestError ? formatDateTime(latestError.created_at) : "-"} sub={latestError?.source || "暂无错误"} />
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary">系统错误日志</h2>
|
||||
<p className="mt-1 text-xs text-text-muted">自动记录应用内 ERROR/CRITICAL 日志,并保留手动上报的数据源和后台任务异常。</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") fetchErrors();
|
||||
}}
|
||||
placeholder="搜索消息、详情或来源"
|
||||
className="w-full rounded-lg border border-border-default bg-surface-2 px-3 py-1.5 text-xs text-text-primary outline-none focus:ring-1 focus:ring-amber-500/30 md:w-48"
|
||||
/>
|
||||
<select value={source} onChange={(event) => setSource(event.target.value)} className="rounded-lg border border-border-default bg-surface-2 px-3 py-1.5 text-xs text-text-primary outline-none">
|
||||
<option value="">全部来源</option>
|
||||
{sources.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
<select value={level} onChange={(event) => setLevel(event.target.value)} className="rounded-lg border border-border-default bg-surface-2 px-3 py-1.5 text-xs text-text-primary outline-none">
|
||||
<option value="">全部级别</option>
|
||||
{levels.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
<button onClick={fetchErrors} className="rounded-lg border border-border-subtle bg-surface-2 px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-4 hover:text-text-primary">
|
||||
查询
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setSource("");
|
||||
setLevel("");
|
||||
}}
|
||||
className="rounded-lg border border-border-subtle bg-surface-2 px-3 py-1.5 text-xs text-text-muted hover:bg-surface-4 hover:text-text-secondary"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<button onClick={handleClearErrors} className="rounded-lg border border-red-500/[0.08] bg-red-500/[0.04] px-3 py-1.5 text-xs text-red-400/70 hover:bg-red-500/[0.08] hover:text-red-400">
|
||||
清除30天前
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr]">
|
||||
<aside className="space-y-4">
|
||||
<ErrorBreakdown title="来源分布" items={sourceCounts} active={source} onSelect={setSource} />
|
||||
<ErrorBreakdown title="级别分布" items={levelCounts} active={level} onSelect={setLevel} labeler={statusLabel} />
|
||||
</aside>
|
||||
|
||||
<div className="glass-card-static overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
|
||||
<span className="text-xs font-semibold text-text-primary">错误记录</span>
|
||||
<span className="text-[10px] text-text-muted">显示 {errors.length} / {errorsTotal} 条</span>
|
||||
</div>
|
||||
{errorsLoading ? (
|
||||
<div className="space-y-2 p-4">
|
||||
{[1, 2, 3].map((item) => <div key={item} className="h-16 animate-shimmer rounded-xl bg-surface-2" />)}
|
||||
</div>
|
||||
) : errors.length === 0 ? (
|
||||
<div className="p-10 text-center text-sm text-text-muted">暂无错误日志</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{errors.map((item) => (
|
||||
<ErrorRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
expanded={expandedErrorId === item.id}
|
||||
onToggle={() => setExpandedErrorId(expandedErrorId === item.id ? null : item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBreakdown({
|
||||
title,
|
||||
items,
|
||||
active,
|
||||
onSelect,
|
||||
labeler,
|
||||
}: {
|
||||
title: string;
|
||||
items: Record<string, number>;
|
||||
active: string;
|
||||
onSelect: (value: string) => void;
|
||||
labeler?: (value: string) => string;
|
||||
}) {
|
||||
const rows = Object.entries(items).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
||||
const max = Math.max(...rows.map(([, count]) => count), 1);
|
||||
return (
|
||||
<div className="glass-card-static p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold text-text-primary">{title}</h3>
|
||||
{active ? (
|
||||
<button onClick={() => onSelect("")} className="text-[10px] text-text-muted hover:text-text-secondary">
|
||||
清除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{rows.length === 0 ? (
|
||||
<div className="rounded-xl border border-border-subtle bg-surface-1/70 p-5 text-xs text-text-muted">暂无分布</div>
|
||||
) : rows.map(([key, count]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onSelect(active === key ? "" : key)}
|
||||
className={`w-full rounded-xl border px-3 py-2 text-left transition-all ${
|
||||
active === key
|
||||
? "border-amber-500/20 bg-amber-500/[0.07]"
|
||||
: "border-border-subtle bg-surface-1/70 hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="truncate text-xs text-text-secondary">{labeler ? labeler(key) : key}</span>
|
||||
<span className="font-mono text-xs tabular-nums text-text-primary">{count}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-bg-primary/60">
|
||||
<div className="h-full rounded-full bg-amber-400/70" style={{ width: `${Math.max(6, Math.round((count / max) * 100))}%` }} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorRow({ item, expanded, onToggle }: { item: ErrorLog; expanded: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
<button className="w-full text-left" onClick={onToggle}>
|
||||
<div className="grid gap-2 md:grid-cols-[88px_160px_1fr_140px] md:items-center">
|
||||
<StatusPill status={item.level} />
|
||||
<span className="truncate font-mono text-xs text-text-muted">{item.source}</span>
|
||||
<span className="truncate text-sm text-text-secondary">{item.message}</span>
|
||||
<span className="text-[10px] font-mono tabular-nums text-text-muted md:text-right">{formatDateTime(item.created_at)}</span>
|
||||
</div>
|
||||
</button>
|
||||
{expanded ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="rounded-xl border border-border-subtle bg-surface-1 p-3">
|
||||
<div className="text-[10px] uppercase tracking-wider text-text-muted">Message</div>
|
||||
<div className="mt-2 break-words text-sm leading-6 text-text-secondary">{item.message}</div>
|
||||
</div>
|
||||
{item.detail ? (
|
||||
<pre className="max-h-80 overflow-auto rounded-xl border border-border-subtle bg-bg-primary/70 p-3 text-xs leading-6 text-text-muted whitespace-pre-wrap">
|
||||
{item.detail}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border-subtle bg-surface-1 p-3 text-xs text-text-muted">没有堆栈详情</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FunnelStage({ log, index, maxCount }: { log: ScanProcessLog; index: number; maxCount: number }) {
|
||||
const outputWidth = Math.max(4, Math.round((log.output_count / maxCount) * 100));
|
||||
const dropWidth = Math.max(0, Math.round((log.filtered_count / maxCount) * 100));
|
||||
const detailItems = extractDetailItems(log);
|
||||
|
||||
return (
|
||||
<article className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[42px_1fr_260px] lg:items-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-border-subtle bg-surface-2 font-mono text-xs tabular-nums text-text-secondary">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-text-primary">{log.stage_label}</h3>
|
||||
<StatusPill status={log.status} />
|
||||
<span className="text-[10px] font-mono tabular-nums text-text-muted">{formatDateTime(log.created_at)}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-text-secondary">{log.summary || "暂无摘要"}</p>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-surface-2">
|
||||
<div className="flex h-full">
|
||||
<div className="h-full bg-amber-400/70" style={{ width: `${outputWidth}%` }} />
|
||||
<div className="h-full bg-red-400/40" style={{ width: `${dropWidth}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<TinyMetric label="输入" value={log.input_count} />
|
||||
<TinyMetric label="输出" value={log.output_count} />
|
||||
<TinyMetric label="过滤" value={log.filtered_count} />
|
||||
</div>
|
||||
</div>
|
||||
{detailItems.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{detailItems.map((item) => (
|
||||
<span key={item} className="rounded-lg border border-border-subtle bg-surface-2 px-2 py-1 text-[10px] text-text-muted">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function extractDetailItems(log: ScanProcessLog) {
|
||||
const detail = log.detail || {};
|
||||
if (log.stage === "candidate_recall") {
|
||||
const routes = detail.route_counts as Record<string, number> | undefined;
|
||||
return routes ? Object.entries(routes).map(([key, value]) => `${routeLabel(key)} ${value}`) : [];
|
||||
}
|
||||
if (log.stage === "rule_scoring") {
|
||||
const skipped = detail.skipped_counts as Record<string, number> | undefined;
|
||||
const signals = detail.signal_counts as Record<string, number> | undefined;
|
||||
return [
|
||||
...(skipped ? Object.entries(skipped).filter(([, value]) => value > 0).map(([key, value]) => `${skipLabel(key)} ${value}`) : []),
|
||||
...(signals ? Object.entries(signals).filter(([, value]) => value > 0).map(([key, value]) => `${signalLabel(key)} ${value}`) : []),
|
||||
].slice(0, 10);
|
||||
}
|
||||
if (log.stage === "final_filter") {
|
||||
const actions = detail.action_counts as Record<string, number> | undefined;
|
||||
const reasons = detail.elimination_reasons as Record<string, number> | undefined;
|
||||
return [
|
||||
...(actions ? Object.entries(actions).map(([key, value]) => `${key} ${value}`) : []),
|
||||
...(reasons ? Object.entries(reasons).slice(0, 6).map(([key, value]) => `${key} ${value}`) : []),
|
||||
];
|
||||
}
|
||||
if (log.stage === "theme_selection") {
|
||||
const themes = detail.themes as Array<{ name?: string; heat_score?: number }> | undefined;
|
||||
return themes?.slice(0, 5).map((item) => `${item.name || "主题"} ${Math.round(Number(item.heat_score || 0))}`) || [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function ResearchRow({ item }: { item: ResearchObservation }) {
|
||||
return (
|
||||
<article className="grid gap-3 px-4 py-3 lg:grid-cols-[160px_90px_1fr_300px] lg:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-text-primary">{item.name}</div>
|
||||
<div className="mt-1 font-mono text-[10px] text-text-muted">{item.ts_code}</div>
|
||||
</div>
|
||||
<div className="font-mono text-lg font-bold tabular-nums text-amber-400">{item.final_score.toFixed(1)}</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="rounded-md border border-border-subtle bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">{item.theme_name || "未归类"}</span>
|
||||
<span className="rounded-md border border-border-subtle bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">{item.stock_role || "候选"}</span>
|
||||
<span className="rounded-md border border-border-subtle bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">{signalLabel(item.entry_signal_type)}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-text-muted line-clamp-2">{item.elimination_reason || "待确认"}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
<ScorePill label="催化" value={item.catalyst_score} />
|
||||
<ScorePill label="题材" value={item.theme_money_score} />
|
||||
<ScorePill label="资金" value={item.stock_money_score} />
|
||||
<ScorePill label="角色" value={item.emotion_role_score} />
|
||||
<ScorePill label="时机" value={item.timing_score} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ScorePill({ 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>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, sub, tone = "muted" }: { label: string; value: string | number; sub: string; tone?: "primary" | "warning" | "danger" | "muted" }) {
|
||||
const valueClass = tone === "primary" ? "text-amber-400" : tone === "warning" ? "text-amber-300" : tone === "danger" ? "text-red-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 truncate text-xl font-bold tabular-nums ${valueClass}`}>{value}</div>
|
||||
<div className="mt-1 line-clamp-1 text-[11px] text-text-muted">{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TinyMetric({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-2 px-2 py-1.5">
|
||||
<div className="text-[9px] text-text-muted/60">{label}</div>
|
||||
<div className="mt-0.5 truncate font-mono text-[11px] font-semibold tabular-nums text-text-secondary">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: string }) {
|
||||
const normalized = status.toLowerCase();
|
||||
const className = normalized === "ok"
|
||||
? "border-emerald-500/15 bg-emerald-500/[0.06] text-emerald-400"
|
||||
: normalized === "warning" || normalized === "empty"
|
||||
? "border-amber-500/15 bg-amber-500/[0.07] text-amber-400"
|
||||
: "border-red-500/15 bg-red-500/[0.07] text-red-400";
|
||||
return (
|
||||
<span className={`inline-flex w-fit items-center rounded-md border px-2 py-0.5 text-[10px] font-medium ${className}`}>
|
||||
{statusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
const normalized = status.toLowerCase();
|
||||
if (normalized === "ok") return "正常";
|
||||
if (normalized === "warning") return "警告";
|
||||
if (normalized === "empty") return "空结果";
|
||||
if (normalized === "error") return "错误";
|
||||
if (normalized === "critical") return "严重";
|
||||
return status || "未知";
|
||||
}
|
||||
|
||||
function routeLabel(key: string) {
|
||||
const map: Record<string, string> = {
|
||||
sector_recall: "主线召回",
|
||||
trend_scan: "趋势召回",
|
||||
intraday_active: "盘中异动",
|
||||
realtime_market: "全市场异动",
|
||||
};
|
||||
return map[key] || key;
|
||||
}
|
||||
|
||||
function skipLabel(key: string) {
|
||||
const map: Record<string, string> = {
|
||||
missing_code: "缺代码",
|
||||
kline_empty: "K线不足",
|
||||
stale_kline: "K线过期",
|
||||
exception: "评分异常",
|
||||
};
|
||||
return map[key] || key;
|
||||
}
|
||||
|
||||
function signalLabel(key: string) {
|
||||
const map: Record<string, string> = {
|
||||
breakout: "突破",
|
||||
breakout_confirm: "确认",
|
||||
pullback: "回踩",
|
||||
launch: "启动",
|
||||
reversal: "反转",
|
||||
none: "无信号",
|
||||
};
|
||||
return map[key] || key;
|
||||
}
|
||||
|
||||
function shortSession(session: string) {
|
||||
if (!session) return "-";
|
||||
return session.length > 20 ? `${session.slice(0, 10)}...${session.slice(-6)}` : session;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
|
||||
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function topSourceLabel(counts: Record<string, number>) {
|
||||
const [source, count] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0] || [];
|
||||
return source ? `${source} ${count} 条` : "暂无来源";
|
||||
}
|
||||
@ -1,613 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
createInviteCodeAPI,
|
||||
dataResetAPI,
|
||||
disableUserAPI,
|
||||
getDataStatsAPI,
|
||||
listInviteCodesAPI,
|
||||
listUsersAPI,
|
||||
resetPasswordAPI,
|
||||
toggleInviteCodeAPI,
|
||||
type DataStats,
|
||||
type InviteCodeItem,
|
||||
type UserItem,
|
||||
} from "@/lib/api";
|
||||
|
||||
type Section = "access" | "data";
|
||||
type ResetMode = "low_score" | "date_range" | "recommendations" | "market_cache" | "diagnostics" | "logs" | "all";
|
||||
|
||||
const RESET_OPTIONS: Array<{ key: ResetMode; title: string; scope: string; danger: "low" | "medium" | "high" }> = [
|
||||
{ key: "low_score", title: "清理低分推荐", scope: "删除评分低于 60 的推荐及其跟踪,保留有效复盘样本。", danger: "low" },
|
||||
{ key: "date_range", title: "按日期归档清理", scope: "删除指定日期之前的推荐、跟踪、市场缓存和诊断记录。", danger: "medium" },
|
||||
{ key: "recommendations", title: "清空推荐闭环", scope: "删除推荐池和推荐跟踪,策略复盘会失去历史样本。", danger: "high" },
|
||||
{ key: "market_cache", title: "清空市场缓存", scope: "删除板块热度和市场温度,下次扫描会重新生成。", danger: "medium" },
|
||||
{ key: "diagnostics", title: "清空诊断记录", scope: "删除单股诊断和自选股分析,不影响推荐池。", danger: "medium" },
|
||||
{ key: "logs", title: "清空运行日志", scope: "删除错误日志、扫描过程和候选观察记录。", danger: "medium" },
|
||||
{ key: "all", title: "重置业务数据", scope: "删除推荐、跟踪、市场缓存和诊断记录,用户和邀请码保留。", danger: "high" },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [section, setSection] = useState<Section>("access");
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [inviteCodes, setInviteCodes] = useState<InviteCodeItem[]>([]);
|
||||
const [dataStats, setDataStats] = useState<DataStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const [showInviteDialog, setShowInviteDialog] = useState(false);
|
||||
const [inviteCode, setInviteCode] = useState("");
|
||||
const [inviteDescription, setInviteDescription] = useState("");
|
||||
const [inviteMaxUses, setInviteMaxUses] = useState("10");
|
||||
const [createdInviteCode, setCreatedInviteCode] = useState<string | null>(null);
|
||||
const [creatingInvite, setCreatingInvite] = useState(false);
|
||||
|
||||
const [resetResult, setResetResult] = useState<{ email: string; password: string } | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState("");
|
||||
|
||||
const [resetMode, setResetMode] = useState<ResetMode>("low_score");
|
||||
const [beforeDate, setBeforeDate] = useState("");
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [userRows, inviteRows, stats] = await Promise.all([
|
||||
listUsersAPI(),
|
||||
listInviteCodesAPI(),
|
||||
getDataStatsAPI(),
|
||||
]);
|
||||
setUsers(userRows);
|
||||
setInviteCodes(inviteRows);
|
||||
setDataStats(stats);
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "加载设置失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser?.role === "admin") loadAll();
|
||||
}, [currentUser?.role, loadAll]);
|
||||
|
||||
const userSummary = useMemo(() => {
|
||||
const active = users.filter((item) => item.is_active).length;
|
||||
const admins = users.filter((item) => item.role === "admin").length;
|
||||
return { active, disabled: users.length - active, admins };
|
||||
}, [users]);
|
||||
|
||||
const inviteSummary = useMemo(() => {
|
||||
const active = inviteCodes.filter((item) => item.is_active).length;
|
||||
const remaining = inviteCodes.reduce((sum, item) => sum + Math.max((item.max_uses ?? 0) - (item.used_count ?? 0), 0), 0);
|
||||
return { active, remaining };
|
||||
}, [inviteCodes]);
|
||||
|
||||
if (currentUser?.role !== "admin") {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6">
|
||||
<p className="text-text-muted text-sm">需要管理员权限</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildInviteLink(code: string) {
|
||||
if (typeof window === "undefined") return `/login?invite=${encodeURIComponent(code)}`;
|
||||
return `${window.location.origin}/login?invite=${encodeURIComponent(code)}`;
|
||||
}
|
||||
|
||||
async function copyText(text: string, key: string) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedKey(key);
|
||||
window.setTimeout(() => setCopiedKey(""), 1800);
|
||||
}
|
||||
|
||||
async function handleCreateInvite(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const normalizedCode = inviteCode.trim().toUpperCase();
|
||||
const maxUses = Number(inviteMaxUses);
|
||||
if (!normalizedCode) {
|
||||
setMessage("请输入邀请码");
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(maxUses) || maxUses < 1) {
|
||||
setMessage("邀请人数上限至少为 1");
|
||||
return;
|
||||
}
|
||||
setCreatingInvite(true);
|
||||
setMessage("");
|
||||
try {
|
||||
const result = await createInviteCodeAPI(normalizedCode, inviteDescription.trim(), maxUses);
|
||||
setCreatedInviteCode(result.code);
|
||||
setInviteCode("");
|
||||
setInviteDescription("");
|
||||
setInviteMaxUses("10");
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "创建邀请码失败");
|
||||
} finally {
|
||||
setCreatingInvite(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisable(userId: number) {
|
||||
try {
|
||||
await disableUserAPI(userId);
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "禁用用户失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword(userId: number) {
|
||||
try {
|
||||
const result = await resetPasswordAPI(userId);
|
||||
setResetResult({ email: result.email, password: result.password });
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "重置密码失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleInvite(inviteId: number) {
|
||||
try {
|
||||
await toggleInviteCodeAPI(inviteId);
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "更新邀请码失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDataReset() {
|
||||
setResetLoading(true);
|
||||
setConfirmReset(false);
|
||||
setMessage("");
|
||||
try {
|
||||
const result = await dataResetAPI(
|
||||
resetMode,
|
||||
resetMode === "date_range" ? beforeDate : undefined,
|
||||
resetMode === "low_score" ? 60 : undefined,
|
||||
);
|
||||
const deletedText = Object.entries(result.deleted)
|
||||
.filter(([, value]) => value > 0)
|
||||
.map(([key, value]) => `${formatDeletedKey(key)} ${value} 条`)
|
||||
.join(",");
|
||||
setMessage(deletedText ? `已清理:${deletedText}` : "没有需要清理的数据");
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "数据维护失败");
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedReset = RESET_OPTIONS.find((item) => item.key === resetMode) ?? RESET_OPTIONS[0];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">Admin</div>
|
||||
<h1 className="mt-2 text-xl font-semibold tracking-tight">管理设置</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-text-secondary">
|
||||
管理注册准入、账号状态和业务数据生命周期。运行日志、数据源健康和任务中心继续保留在侧边栏独立入口。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{[
|
||||
{ key: "access", label: "账号准入" },
|
||||
{ key: "data", label: "数据维护" },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setSection(item.key as Section)}
|
||||
className={`shrink-0 rounded-xl border px-4 py-2 text-sm font-medium transition-all ${
|
||||
section === item.key
|
||||
? "border-amber-500/20 bg-amber-500/[0.08] text-amber-400"
|
||||
: "border-border-subtle bg-surface-1 text-text-muted hover:text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
<div className="rounded-xl border border-amber-500/15 bg-amber-500/[0.06] px-4 py-3 text-sm text-amber-300">
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="h-40 glass-card-static animate-shimmer" />
|
||||
<div className="h-40 glass-card-static animate-shimmer" />
|
||||
<div className="h-40 glass-card-static animate-shimmer" />
|
||||
</div>
|
||||
) : section === "access" ? (
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Metric label="活跃用户" value={userSummary.active} />
|
||||
<Metric label="管理员" value={userSummary.admins} />
|
||||
<Metric label="启用邀请码" value={inviteSummary.active} />
|
||||
<Metric label="剩余邀请名额" value={inviteSummary.remaining} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.2fr)_minmax(360px,0.8fr)] gap-5">
|
||||
<section className="glass-card-static p-4 md:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-bold tracking-tight text-text-primary">用户列表</h2>
|
||||
<p className="mt-1 text-xs text-text-muted">账号状态、角色、注册来源和密码重置。</p>
|
||||
</div>
|
||||
<span className="text-xs text-text-muted">{users.length} 个账号</span>
|
||||
</div>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full min-w-[760px] text-left text-sm">
|
||||
<thead className="text-[10px] uppercase tracking-wider text-text-muted">
|
||||
<tr className="border-b border-border-subtle">
|
||||
<th className="py-2 pr-3 font-medium">用户</th>
|
||||
<th className="py-2 pr-3 font-medium">角色</th>
|
||||
<th className="py-2 pr-3 font-medium">状态</th>
|
||||
<th className="py-2 pr-3 font-medium">邀请码</th>
|
||||
<th className="py-2 pr-3 font-medium">创建时间</th>
|
||||
<th className="py-2 font-medium text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((item) => (
|
||||
<tr key={item.id} className="border-b border-border-subtle/70 last:border-0">
|
||||
<td className="py-3 pr-3">
|
||||
<div className="font-medium text-text-primary">{item.email || item.username}</div>
|
||||
<div className="mt-0.5 font-mono text-[11px] text-text-muted">#{item.id}</div>
|
||||
</td>
|
||||
<td className="py-3 pr-3"><Badge tone={item.role === "admin" ? "amber" : "muted"}>{item.role}</Badge></td>
|
||||
<td className="py-3 pr-3"><Badge tone={item.is_active ? "green" : "red"}>{item.is_active ? "启用" : "禁用"}</Badge></td>
|
||||
<td className="py-3 pr-3 font-mono text-xs text-text-secondary">{item.invite_code_used || "-"}</td>
|
||||
<td className="py-3 pr-3 text-xs text-text-muted">{formatDateTime(item.created_at)}</td>
|
||||
<td className="py-3 text-right">
|
||||
{item.id !== currentUser.id ? (
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => handleResetPassword(item.id)} 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>
|
||||
{item.is_active ? (
|
||||
<button onClick={() => handleDisable(item.id)} className="rounded-lg border border-red-500/10 bg-red-500/[0.04] px-3 py-1.5 text-xs text-red-400/80 hover:text-red-400">
|
||||
禁用
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-text-muted">当前账号</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="glass-card-static p-4 md:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-bold tracking-tight text-text-primary">邀请码</h2>
|
||||
<p className="mt-1 text-xs text-text-muted">复制注册链接后,用户打开会自动进入注册并填入邀请码。</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowInviteDialog(true);
|
||||
setCreatedInviteCode(null);
|
||||
setMessage("");
|
||||
}}
|
||||
className="rounded-xl border border-amber-500/20 bg-amber-500/[0.08] px-3 py-2 text-xs font-semibold text-amber-400 hover:bg-amber-500/[0.12]"
|
||||
>
|
||||
新建
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{inviteCodes.length ? inviteCodes.map((item) => (
|
||||
<InviteCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
link={buildInviteLink(item.code)}
|
||||
copiedKey={copiedKey}
|
||||
onCopy={copyText}
|
||||
onToggle={() => handleToggleInvite(item.id)}
|
||||
/>
|
||||
)) : (
|
||||
<div className="rounded-xl border border-border-subtle bg-surface-1/70 p-8 text-center text-sm text-text-muted">暂无邀请码</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_420px] gap-5">
|
||||
<section className="glass-card-static p-4 md:p-5">
|
||||
<div>
|
||||
<h2 className="text-base font-bold tracking-tight text-text-primary">数据资产</h2>
|
||||
<p className="mt-1 text-xs text-text-muted">这里管理的是系统运行产生的业务数据,不管理用户账号本身。</p>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<DataBucket title="推荐闭环" description="推荐池、入场价、跟踪收益、策略复盘样本。" stats={[
|
||||
["推荐", dataStats?.recommendations ?? 0],
|
||||
["跟踪", dataStats?.tracking ?? 0],
|
||||
["低分", dataStats?.low_score_count ?? 0],
|
||||
]} />
|
||||
<DataBucket title="市场缓存" description="市场温度和板块热度,扫描任务可重新生成。" stats={[
|
||||
["市场温度", dataStats?.market_temperature ?? 0],
|
||||
["板块热度", dataStats?.sector_heat ?? 0],
|
||||
]} />
|
||||
<DataBucket title="诊断沉淀" description="单股诊断和自选股分析记录,用于回看历史判断。" stats={[
|
||||
["单股诊断", dataStats?.stock_diagnoses ?? 0],
|
||||
["自选分析", dataStats?.watchlist_analyses ?? 0],
|
||||
]} />
|
||||
<DataBucket title="系统日志" description="错误日志、扫描过程、候选观察,便于排查问题。" stats={[
|
||||
["错误日志", dataStats?.error_logs ?? 0],
|
||||
["扫描日志", dataStats?.scan_logs ?? 0],
|
||||
]} />
|
||||
</div>
|
||||
<div className="mt-4 rounded-xl border border-border-subtle bg-surface-1/60 px-4 py-3 text-xs text-text-muted">
|
||||
当前推荐记录日期范围:{dataStats?.earliest_date || "-"} 到 {dataStats?.latest_date || "-"}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="glass-card-static p-4 md:p-5">
|
||||
<h2 className="text-base font-bold tracking-tight text-text-primary">维护动作</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
{RESET_OPTIONS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => {
|
||||
setResetMode(item.key);
|
||||
setConfirmReset(false);
|
||||
setMessage("");
|
||||
}}
|
||||
className={`w-full rounded-xl border px-3 py-3 text-left transition-all ${
|
||||
resetMode === item.key
|
||||
? "border-amber-500/20 bg-amber-500/[0.07]"
|
||||
: "border-border-subtle bg-surface-1/70 hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-semibold text-text-primary">{item.title}</span>
|
||||
<Badge tone={item.danger === "high" ? "red" : item.danger === "medium" ? "amber" : "green"}>
|
||||
{item.danger === "high" ? "高风险" : item.danger === "medium" ? "谨慎" : "常规"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5 text-text-muted">{item.scope}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{resetMode === "date_range" ? (
|
||||
<div className="mt-4">
|
||||
<label className="text-xs text-text-muted mb-1.5 block">删除此日期之前的数据</label>
|
||||
<input
|
||||
type="date"
|
||||
value={beforeDate}
|
||||
onChange={(event) => setBeforeDate(event.target.value)}
|
||||
className="w-full rounded-xl border border-border-default bg-surface-2 px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 rounded-xl border border-border-subtle bg-surface-1/60 px-3 py-3 text-xs leading-5 text-text-secondary">
|
||||
将执行:{selectedReset.scope}
|
||||
</div>
|
||||
|
||||
{confirmReset ? (
|
||||
<div className="mt-4 rounded-xl border border-red-500/15 bg-red-500/[0.05] p-3">
|
||||
<div className="text-sm font-semibold text-red-300">确认执行这个数据维护动作?</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button onClick={handleDataReset} disabled={resetLoading} className="rounded-lg bg-red-500/20 px-3 py-2 text-xs font-semibold text-red-300 disabled:opacity-50">
|
||||
{resetLoading ? "执行中" : "确认执行"}
|
||||
</button>
|
||||
<button onClick={() => setConfirmReset(false)} className="rounded-lg border border-border-subtle bg-surface-2 px-3 py-2 text-xs text-text-secondary">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmReset(true)}
|
||||
disabled={resetMode === "date_range" && !beforeDate}
|
||||
className="mt-4 w-full rounded-xl border border-red-500/10 bg-red-500/[0.04] px-3 py-2.5 text-sm font-semibold text-red-400/80 hover:text-red-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
执行维护
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showInviteDialog ? (
|
||||
<Modal onClose={() => setShowInviteDialog(false)}>
|
||||
{createdInviteCode ? (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-base font-semibold text-text-primary">邀请码创建成功</h3>
|
||||
<div className="rounded-xl border border-border-subtle bg-surface-1 p-3">
|
||||
<div className="text-xs text-text-muted">注册链接</div>
|
||||
<div className="mt-2 break-all font-mono text-xs text-text-secondary">{buildInviteLink(createdInviteCode)}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button onClick={() => copyText(createdInviteCode, "created-code")} className="rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-sm text-text-secondary hover:text-text-primary">
|
||||
{copiedKey === "created-code" ? "已复制" : "复制邀请码"}
|
||||
</button>
|
||||
<button onClick={() => copyText(buildInviteLink(createdInviteCode), "created-link")} className="rounded-xl border border-amber-500/20 bg-amber-500/[0.08] px-3 py-2 text-sm font-semibold text-amber-400">
|
||||
{copiedKey === "created-link" ? "已复制" : "复制链接"}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => setShowInviteDialog(false)} className="w-full rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-sm text-text-secondary">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleCreateInvite} className="space-y-3">
|
||||
<h3 className="text-base font-semibold text-text-primary">新建邀请码</h3>
|
||||
<input value={inviteCode} onChange={(event) => setInviteCode(event.target.value)} placeholder="邀请码,例如 ASTOCK-VIP-01" className="w-full rounded-xl border border-border-default bg-surface-2 px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30" />
|
||||
<input value={inviteDescription} onChange={(event) => setInviteDescription(event.target.value)} placeholder="说明,例如 内测第一批" className="w-full rounded-xl border border-border-default bg-surface-2 px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30" />
|
||||
<input type="number" min="1" value={inviteMaxUses} onChange={(event) => setInviteMaxUses(event.target.value)} placeholder="邀请人数上限" className="w-full rounded-xl border border-border-default bg-surface-2 px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30" />
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<button type="button" onClick={() => setShowInviteDialog(false)} className="rounded-xl border border-border-subtle bg-surface-2 px-3 py-2.5 text-sm text-text-secondary">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={creatingInvite} className="rounded-xl border border-amber-500/20 bg-amber-500/[0.08] px-3 py-2.5 text-sm font-semibold text-amber-400 disabled:opacity-50">
|
||||
{creatingInvite ? "创建中" : "创建"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{resetResult ? (
|
||||
<Modal onClose={() => setResetResult(null)}>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-base font-semibold text-text-primary">密码已重置</h3>
|
||||
<div className="rounded-xl border border-border-subtle bg-surface-1 p-3 space-y-2">
|
||||
<div className="flex justify-between gap-3 text-sm"><span className="text-text-muted">邮箱</span><span className="text-text-primary">{resetResult.email}</span></div>
|
||||
<div className="flex justify-between gap-3 text-sm"><span className="text-text-muted">新密码</span><span className="font-mono text-amber-400">{resetResult.password}</span></div>
|
||||
</div>
|
||||
<button onClick={() => copyText(`邮箱:${resetResult.email}\n密码:${resetResult.password}`, "password")} className="w-full rounded-xl border border-amber-500/20 bg-amber-500/[0.08] px-3 py-2.5 text-sm font-semibold text-amber-400">
|
||||
{copiedKey === "password" ? "已复制" : "复制登录信息"}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteCard({
|
||||
item,
|
||||
link,
|
||||
copiedKey,
|
||||
onCopy,
|
||||
onToggle,
|
||||
}: {
|
||||
item: InviteCodeItem;
|
||||
link: string;
|
||||
copiedKey: string;
|
||||
onCopy: (text: string, key: string) => void;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const remaining = Math.max(item.max_uses - item.used_count, 0);
|
||||
const exhausted = item.max_uses > 0 && item.used_count >= item.max_uses;
|
||||
return (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/60 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-sm font-semibold text-text-primary">{item.code}</span>
|
||||
<Badge tone={item.is_active ? "green" : "muted"}>{item.is_active ? "启用" : "停用"}</Badge>
|
||||
{exhausted ? <Badge tone="red">已用完</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-muted">{item.description || "无说明"}</div>
|
||||
</div>
|
||||
<button onClick={onToggle} className="shrink-0 rounded-lg border border-border-subtle bg-surface-2 px-2.5 py-1.5 text-xs text-text-secondary hover:text-text-primary">
|
||||
{item.is_active ? "停用" : "启用"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
<MiniStat label="已用" value={item.used_count} />
|
||||
<MiniStat label="上限" value={item.max_uses} />
|
||||
<MiniStat label="剩余" value={remaining} />
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl bg-bg-primary/50 px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted">注册链接</div>
|
||||
<div className="mt-1 truncate font-mono text-[11px] text-text-secondary">{link}</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<button onClick={() => onCopy(item.code, `code-${item.id}`)} className="rounded-lg border border-border-subtle bg-surface-2 px-3 py-2 text-xs text-text-secondary hover:text-text-primary">
|
||||
{copiedKey === `code-${item.id}` ? "已复制" : "复制码"}
|
||||
</button>
|
||||
<button onClick={() => onCopy(link, `link-${item.id}`)} className="rounded-lg border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-xs font-semibold text-amber-400">
|
||||
{copiedKey === `link-${item.id}` ? "已复制" : "复制链接"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataBucket({ title, description, stats }: { title: string; description: string; stats: Array<[string, number]> }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/60 p-4">
|
||||
<div className="text-sm font-semibold text-text-primary">{title}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-text-muted">{description}</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{stats.map(([label, value]) => (
|
||||
<MiniStat key={label} label={label} value={value} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 px-4 py-3">
|
||||
<div className="text-[10px] uppercase tracking-wider text-text-muted">{label}</div>
|
||||
<div className="mt-1 font-mono text-xl font-bold tabular-nums text-text-primary">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniStat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-bg-primary/50 px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted">{label}</div>
|
||||
<div className="mt-1 font-mono text-sm font-semibold tabular-nums text-text-primary">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Badge({ children, tone }: { children: React.ReactNode; tone: "green" | "red" | "amber" | "muted" }) {
|
||||
const className =
|
||||
tone === "green"
|
||||
? "border-emerald-500/15 bg-emerald-500/10 text-emerald-300"
|
||||
: tone === "red"
|
||||
? "border-red-500/15 bg-red-500/10 text-red-300"
|
||||
: tone === "amber"
|
||||
? "border-amber-500/15 bg-amber-500/10 text-amber-300"
|
||||
: "border-border-subtle bg-surface-2 text-text-muted";
|
||||
return <span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${className}`}>{children}</span>;
|
||||
}
|
||||
|
||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<button aria-label="关闭" className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative mx-4 w-full max-w-md rounded-2xl border border-border-default bg-bg-card p-6 shadow-card">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function formatDeletedKey(key: string) {
|
||||
const labels: Record<string, string> = {
|
||||
recommendation_tracking: "跟踪记录",
|
||||
tracking: "跟踪记录",
|
||||
recommendations: "推荐记录",
|
||||
sector_heat: "板块热度",
|
||||
market_temperature: "市场温度",
|
||||
stock_diagnoses: "单股诊断",
|
||||
watchlist_analyses: "自选分析",
|
||||
error_logs: "错误日志",
|
||||
scan_process_logs: "扫描日志",
|
||||
research_observations: "候选观察",
|
||||
};
|
||||
return labels[key] ?? key;
|
||||
}
|
||||
@ -1,628 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { fetchAPI, postAPI } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import type {
|
||||
AgentPatchPrompt,
|
||||
PerformanceStats,
|
||||
StrategyConfigCenter,
|
||||
StrategyConfigChange,
|
||||
StrategyConfigRecord,
|
||||
StrategyAdjustment,
|
||||
StrategyIterationReport,
|
||||
StrategyStat,
|
||||
} from "@/lib/api";
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
tighten: "收紧",
|
||||
promote: "加强",
|
||||
reduce: "降权",
|
||||
keep: "保持",
|
||||
observe: "观察",
|
||||
};
|
||||
|
||||
export default function StrategyPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [iteration, setIteration] = useState<StrategyIterationReport | null>(null);
|
||||
const [performance, setPerformance] = useState<PerformanceStats | null>(null);
|
||||
const [configCenter, setConfigCenter] = useState<StrategyConfigCenter | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionMessage, setActionMessage] = useState("");
|
||||
const [copiedPrompt, setCopiedPrompt] = useState("");
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [iterationReport, perf, configs] = await Promise.all([
|
||||
fetchAPI<StrategyIterationReport>("/api/market/strategy-iteration?limit=80").catch(() => null),
|
||||
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
|
||||
fetchAPI<StrategyConfigCenter>("/api/market/strategy-configs").catch(() => null),
|
||||
]);
|
||||
setIteration(iterationReport);
|
||||
setPerformance(perf);
|
||||
setConfigCenter(configs);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const generateIteration = useCallback(async () => {
|
||||
setActionMessage("正在生成策略复盘...");
|
||||
try {
|
||||
const report = await postAPI<StrategyIterationReport>("/api/market/generate-strategy-iteration?limit=80");
|
||||
const configs = await fetchAPI<StrategyConfigCenter>("/api/market/strategy-configs");
|
||||
setIteration(report);
|
||||
setConfigCenter(configs);
|
||||
setActionMessage(report.auto_config_change ? "已生成复盘,并自动写入一条小幅配置调整。" : "已生成复盘,本次没有触发自动配置调整。");
|
||||
} catch (e) {
|
||||
setActionMessage(e instanceof Error ? e.message : "策略复盘生成失败");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const rollbackConfig = useCallback(async (strategyId: string) => {
|
||||
setActionMessage(`正在回滚 ${strategyId}...`);
|
||||
try {
|
||||
await postAPI(`/api/market/strategy-configs/${strategyId}/rollback`);
|
||||
await loadData();
|
||||
setActionMessage(`${strategyId} 已回滚到上一版本。`);
|
||||
} catch (e) {
|
||||
setActionMessage(e instanceof Error ? e.message : "回滚失败");
|
||||
}
|
||||
}, [loadData]);
|
||||
|
||||
const copyPatchPrompt = useCallback(async (item: AgentPatchPrompt) => {
|
||||
await navigator.clipboard.writeText(item.prompt);
|
||||
setCopiedPrompt(item.title);
|
||||
window.setTimeout(() => setCopiedPrompt(""), 1800);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && user?.role !== "admin") {
|
||||
router.replace("/dashboard");
|
||||
return;
|
||||
}
|
||||
}, [authLoading, router, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading || user?.role !== "admin") return;
|
||||
loadData();
|
||||
}, [authLoading, loadData, user]);
|
||||
|
||||
const diagnosis = useMemo(() => buildCalibrationDiagnosis(iteration, performance), [iteration, performance]);
|
||||
const safeWinRate = clampPercent(performance?.win_rate ?? 0);
|
||||
|
||||
if (authLoading || user?.role !== "admin" || loading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 space-y-4">
|
||||
<div className="h-32 glass-card-static animate-shimmer" />
|
||||
<div className="h-64 glass-card-static animate-shimmer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||
<div className="animate-fade-in-up">
|
||||
<h1 className="text-xl font-bold tracking-tight">系统校准</h1>
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static p-3 animate-fade-in-up flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-primary">配置化自我迭代</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{actionMessage ? <span className="text-xs text-amber-400">{actionMessage}</span> : null}
|
||||
<button
|
||||
onClick={generateIteration}
|
||||
className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-xs font-semibold text-amber-400 transition-colors hover:bg-amber-500/15"
|
||||
>
|
||||
生成复盘并校准
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">系统当前判断</div>
|
||||
<h2 className="mt-2 text-xl font-bold tracking-tight text-text-primary">{diagnosis.headline}</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-text-secondary line-clamp-2">{diagnosis.detail}</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<DecisionList title="用途" items={diagnosis.useFor} tone="positive" />
|
||||
<DecisionList title="边界" items={diagnosis.notFor} tone="risk" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 self-start">
|
||||
<MetricCard label="复盘样本" value={iteration?.sample_size ?? 0} />
|
||||
<MetricCard label="整体胜率" value={`${safeWinRate.toFixed(1)}%`} tone={safeWinRate >= 50 ? "up" : "down"} />
|
||||
<MetricCard label="平均收益" value={`${(performance?.avg_return ?? 0) > 0 ? "+" : ""}${(performance?.avg_return ?? 0).toFixed(2)}%`} tone={(performance?.avg_return ?? 0) >= 0 ? "up" : "down"} />
|
||||
<MetricCard label="平均回撤" value={`${(performance?.avg_max_drawdown ?? 0).toFixed(2)}%`} tone="risk" />
|
||||
<MetricFact label="已跟踪" value={`${performance?.tracked ?? 0} 只`} />
|
||||
<MetricFact label="页面角色" value="方法迭代" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{iteration ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px] gap-4">
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
|
||||
<div>
|
||||
<SectionTitle title="校准摘要" />
|
||||
<p className="mt-2 text-sm leading-7 text-text-secondary">
|
||||
{iteration.summary}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-[10px] text-text-muted font-mono tabular-nums">
|
||||
{new Date(iteration.generated_at).toLocaleString("zh-CN")}
|
||||
</div>
|
||||
</div>
|
||||
{iteration.ai_analysis ? (
|
||||
<div className="mt-4 rounded-2xl border border-cyan-500/10 bg-cyan-500/[0.04] p-4 text-sm leading-7 text-cyan-400/85">
|
||||
{iteration.ai_analysis}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ReviewWindowsPanel windows={iteration.review_windows ?? []} />
|
||||
</div>
|
||||
|
||||
<ConfigCenterPanel
|
||||
configs={configCenter}
|
||||
onRollback={rollbackConfig}
|
||||
/>
|
||||
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<SectionTitle title="下一轮系统指令" />
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{(iteration.adjustment_suggestions.length
|
||||
? iteration.adjustment_suggestions
|
||||
: [{ target: "推荐系统", action: "observe", reason: "等待更多跟踪样本后再调整策略权重。", confidence: "低" }]
|
||||
).slice(0, 6).map((item, index) => (
|
||||
<NextInstruction key={`${item.target}-${index}`} item={item} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<StatsPanel
|
||||
title="哪些策略更有效"
|
||||
description="按策略分组"
|
||||
stats={iteration.strategy_stats}
|
||||
/>
|
||||
<StatsPanel
|
||||
title="哪些信号更有效"
|
||||
description="按信号分组"
|
||||
stats={iteration.signal_stats}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AgentPatchPromptPanel
|
||||
prompts={iteration.agent_patch_prompts ?? []}
|
||||
copiedTitle={copiedPrompt}
|
||||
onCopy={copyPatchPrompt}
|
||||
/>
|
||||
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<SectionTitle title="最近的失效模式" />
|
||||
<div className="mt-4 space-y-2">
|
||||
{iteration.failure_patterns.length ? (
|
||||
iteration.failure_patterns.map((pattern, index) => (
|
||||
<div key={index} className="rounded-2xl border border-border-subtle bg-surface-1 px-3 py-3 text-sm leading-6 text-text-secondary">
|
||||
{pattern}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1 px-3 py-3 text-sm text-text-muted">
|
||||
暂无明确失效模式。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="glass-card-static p-10 text-center">
|
||||
<div className="text-text-muted text-sm">暂无系统校准数据</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildCalibrationDiagnosis(
|
||||
iteration: StrategyIterationReport | null,
|
||||
performance: PerformanceStats | null
|
||||
) {
|
||||
const winRate = clampPercent(performance?.win_rate ?? 0);
|
||||
const avgReturn = performance?.avg_return ?? 0;
|
||||
const tracked = performance?.tracked ?? 0;
|
||||
const headline =
|
||||
tracked < 10
|
||||
? "样本积累中"
|
||||
: winRate >= 55 && avgReturn >= 0
|
||||
? "方法有效"
|
||||
: "方法退化";
|
||||
|
||||
const detail =
|
||||
iteration?.summary ??
|
||||
(tracked < 10
|
||||
? "闭环样本不足,只看方向偏差。"
|
||||
: "检查推荐方法偏差和下一轮调整。");
|
||||
|
||||
const useFor = [
|
||||
"验证推荐兑现率。",
|
||||
"识别有效策略和信号。",
|
||||
"生成下一轮配置调整。",
|
||||
];
|
||||
|
||||
const notFor = [
|
||||
"不做盘中买卖决策。",
|
||||
"不替代板块行情。",
|
||||
"不展开单股长逻辑。",
|
||||
];
|
||||
|
||||
return { headline, detail, useFor, notFor };
|
||||
}
|
||||
|
||||
function clampPercent(value: number) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function DecisionList({
|
||||
title,
|
||||
items,
|
||||
tone,
|
||||
}: {
|
||||
title: string;
|
||||
items: string[];
|
||||
tone: "positive" | "risk";
|
||||
}) {
|
||||
const dotClass = tone === "positive" ? "bg-emerald-400" : "bg-amber-400";
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
|
||||
<div className="text-[11px] font-semibold text-text-secondary">{title}</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<div key={`${title}-${index}`} className="flex items-start gap-2 text-sm text-text-secondary">
|
||||
<span className={`mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full ${dotClass}`} />
|
||||
<span className="leading-6">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigCenterPanel({
|
||||
configs,
|
||||
onRollback,
|
||||
}: {
|
||||
configs: StrategyConfigCenter | null;
|
||||
onRollback: (strategyId: string) => void;
|
||||
}) {
|
||||
const strategies = configs?.strategies ?? [];
|
||||
const prompts = configs?.prompts ?? [];
|
||||
const changes = configs?.changes ?? [];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_420px] gap-4">
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<SectionTitle title="当前策略配置版本" />
|
||||
<span className="text-xs text-text-muted">下一轮扫描直接读取</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{strategies.length ? strategies.map((item) => (
|
||||
<StrategyConfigCard key={item.strategy_id} item={item} onRollback={onRollback} />
|
||||
)) : (
|
||||
<div className="text-sm text-text-muted">暂无配置数据。</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5 border-t border-border-subtle pt-4">
|
||||
<div className="text-[11px] font-semibold text-text-secondary">Prompt 版本</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{prompts.length ? prompts.map((item) => (
|
||||
<span key={item.prompt_key} className="rounded-lg border border-border-subtle bg-surface-2 px-2 py-1 text-[10px] text-text-muted">
|
||||
{item.prompt_key} · v{item.version}
|
||||
</span>
|
||||
)) : (
|
||||
<span className="text-xs text-text-muted">暂无 Prompt 配置。</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<SectionTitle title="最近配置变更" />
|
||||
<div className="mt-4 space-y-3">
|
||||
{changes.length ? changes.slice(0, 6).map((item) => (
|
||||
<ConfigChangeRow key={item.id} item={item} />
|
||||
)) : (
|
||||
<div className="text-sm text-text-muted">暂无变更记录。</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StrategyConfigCard({ item, onRollback }: { item: StrategyConfigRecord; onRollback: (strategyId: string) => void }) {
|
||||
const cfg = item.config;
|
||||
const scoreWeights = cfg.score_weights as Record<string, number> | undefined;
|
||||
const weightLabels: Record<string, string> = {
|
||||
catalyst: "催化",
|
||||
theme_money: "主题资金",
|
||||
stock_money: "个股资金",
|
||||
emotion_role: "情绪角色",
|
||||
timing: "时机",
|
||||
capital_momentum: "资金顺势",
|
||||
supply_demand: "供需",
|
||||
price_action: "价格行为",
|
||||
trend: "趋势",
|
||||
};
|
||||
const weightText = scoreWeights
|
||||
? Object.entries(scoreWeights).map(([key, value]) => `${weightLabels[key] ?? key}:${Number(value).toFixed(2)}`).join(" / ")
|
||||
: "暂无权重";
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-text-primary">{item.strategy_id}</div>
|
||||
<div className="mt-1 text-[11px] text-text-muted">v{item.version} · {item.source}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onRollback(item.strategy_id)}
|
||||
className="shrink-0 rounded-lg border border-border-subtle bg-surface-2 px-2 py-1 text-[10px] text-text-secondary transition-colors hover:border-amber-500/20 hover:text-amber-400"
|
||||
>
|
||||
回滚
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-[10px]">
|
||||
<StatCell label="买入阈值" value={String(cfg.buy_threshold ?? "-")} />
|
||||
<StatCell label="最低分" value={String(cfg.min_score ?? "-")} />
|
||||
<StatCell label="仓位上限" value={`${cfg.max_position_pct ?? "-"}%`} />
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl bg-surface-2/70 px-3 py-2 text-[11px] leading-5 text-text-muted">
|
||||
{weightText}
|
||||
</div>
|
||||
{item.change_reason ? (
|
||||
<div className="mt-2 text-xs leading-5 text-text-secondary">{item.change_reason}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigChangeRow({ item }: { item: StrategyConfigChange }) {
|
||||
const diffEntries = Object.entries(item.diff ?? {}).slice(0, 4);
|
||||
return (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-primary">{item.strategy_id || item.prompt_key || "配置变更"}</div>
|
||||
<div className="mt-1 text-[11px] text-text-muted">
|
||||
{item.change_type} · v{item.base_version} → v{item.new_version}
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-subtle bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-text-secondary">{item.reason || "暂无说明"}</div>
|
||||
{diffEntries.length ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{diffEntries.map(([key, value]) => (
|
||||
<div key={`${item.id}-${key}`} className="rounded-lg bg-surface-2/70 px-2 py-1 text-[10px] text-text-muted">
|
||||
{key}: {formatUnknown(value.from)} → {formatUnknown(value.to)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewWindowsPanel({ windows }: { windows: NonNullable<StrategyIterationReport["review_windows"]> }) {
|
||||
return (
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<SectionTitle title="3/5/10 日复盘窗口" />
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{windows.length ? windows.map((item) => (
|
||||
<div key={item.window_days} className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
||||
<div className="text-sm font-semibold text-text-primary">T+{item.window_days}</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<StatCell label="样本" value={item.count} />
|
||||
<StatCell label="胜率" value={`${item.win_rate.toFixed(1)}%`} />
|
||||
<StatCell label="收益" value={`${item.avg_return > 0 ? "+" : ""}${item.avg_return.toFixed(2)}%`} />
|
||||
<StatCell label="目标率" value={`${item.hit_target_rate.toFixed(1)}%`} />
|
||||
<StatCell label="止损率" value={`${item.hit_stop_rate.toFixed(1)}%`} />
|
||||
<StatCell label="回撤" value={`${item.avg_max_drawdown.toFixed(1)}%`} />
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="text-sm text-text-muted">暂无窗口样本。</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentPatchPromptPanel({
|
||||
prompts,
|
||||
copiedTitle,
|
||||
onCopy,
|
||||
}: {
|
||||
prompts: AgentPatchPrompt[];
|
||||
copiedTitle: string;
|
||||
onCopy: (item: AgentPatchPrompt) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<SectionTitle title="Agent 修改提示词" />
|
||||
<span className="text-xs text-text-muted">大幅策略改造先人工审查</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{prompts.length ? prompts.map((item) => (
|
||||
<div key={item.title} className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-primary">{item.title}</div>
|
||||
<div className="mt-1 text-xs text-text-muted">{item.evidence}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onCopy(item)}
|
||||
className="shrink-0 rounded-xl border border-cyan-500/15 bg-cyan-500/[0.05] px-3 py-2 text-xs font-semibold text-cyan-400 transition-colors hover:bg-cyan-500/10"
|
||||
>
|
||||
{copiedTitle === item.title ? "已复制" : "复制提示词"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl bg-surface-2/70 p-3 text-xs leading-6 text-text-secondary">
|
||||
{item.prompt}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{item.target_files.map((file) => (
|
||||
<span key={file} className="rounded-lg border border-border-subtle bg-surface-2 px-2 py-1 text-[10px] text-text-muted">
|
||||
{file}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4 text-sm text-text-muted">
|
||||
当前样本不足或没有集中失效模式,暂不生成代码级改造提示词。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatUnknown(value: unknown): string {
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
if (value == null) return "空";
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return "复杂配置";
|
||||
}
|
||||
}
|
||||
|
||||
function NextInstruction({ item, index }: { item: StrategyAdjustment; index: number }) {
|
||||
const verb = ACTION_LABELS[item.action] ?? item.action;
|
||||
const color =
|
||||
item.action === "promote"
|
||||
? "text-red-400 bg-red-500/[0.04] border-red-500/15"
|
||||
: item.action === "tighten" || item.action === "reduce"
|
||||
? "text-amber-400 bg-amber-500/[0.04] border-amber-500/15"
|
||||
: "text-cyan-400 bg-cyan-500/[0.04] border-cyan-500/15";
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border p-3 ${color}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider opacity-80 mb-1">
|
||||
指令 {index + 1} · {verb}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-text-primary">{item.target}</div>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full border border-border-subtle bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">
|
||||
置信 {item.confidence}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-text-secondary">{item.reason}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
tone?: "up" | "down" | "risk";
|
||||
}) {
|
||||
const color = tone === "up" ? "text-red-400" : tone === "down" ? "text-emerald-400" : tone === "risk" ? "text-amber-400" : "text-text-primary";
|
||||
|
||||
return (
|
||||
<div className="glass-card-static p-4">
|
||||
<div className="text-[10px] text-text-muted/60 mb-1">{label}</div>
|
||||
<div className={`text-xl font-bold font-mono tabular-nums ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricFact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="glass-card-static p-4">
|
||||
<div className="text-[10px] text-text-muted/60 mb-1">{label}</div>
|
||||
<div className="text-xs font-semibold leading-5 text-text-secondary">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsPanel({
|
||||
title,
|
||||
description,
|
||||
stats,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
stats: StrategyStat[];
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<SectionTitle title={title} />
|
||||
<div className="mt-1 text-xs text-text-muted">{description}</div>
|
||||
<div className="space-y-2 mt-4">
|
||||
{stats.length ? (
|
||||
stats.slice(0, 6).map((stat) => (
|
||||
<div key={stat.name} className="rounded-2xl bg-surface-1 border border-border-subtle p-3">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div className="text-sm font-semibold text-text-primary truncate">{stat.name}</div>
|
||||
<div className={`text-sm font-bold font-mono tabular-nums ${stat.avg_return > 0 ? "text-red-400" : stat.avg_return < 0 ? "text-emerald-400" : "text-text-secondary"}`}>
|
||||
{stat.avg_return > 0 ? "+" : ""}{stat.avg_return.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 text-[10px] text-text-muted">
|
||||
<StatCell label="样本" value={stat.count} />
|
||||
<StatCell label="胜率" value={`${stat.win_rate.toFixed(1)}%`} />
|
||||
<StatCell label="浮盈" value={`${stat.avg_max_return > 0 ? "+" : ""}${stat.avg_max_return.toFixed(1)}%`} />
|
||||
<StatCell label="回撤" value={`${stat.avg_max_drawdown.toFixed(1)}%`} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-text-muted">暂无分组数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCell({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-2 px-2 py-1.5">
|
||||
<div className="text-[9px] text-text-muted/50">{label}</div>
|
||||
<div className="text-[11px] text-text-secondary font-mono tabular-nums mt-0.5">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,211 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { getTaskCenterAPI, type TaskCenterResult } from "@/lib/api";
|
||||
|
||||
export default function TasksPage() {
|
||||
const { user } = useAuth();
|
||||
const [data, setData] = useState<TaskCenterResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(await getTaskCenterAPI());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role === "admin") loadData();
|
||||
}, [user, loadData]);
|
||||
|
||||
const jobGroups = useMemo(() => {
|
||||
const jobs = data?.jobs || [];
|
||||
return {
|
||||
news: jobs.filter((job) => job.id.startsWith("news")),
|
||||
scan: jobs.filter((job) => job.id.includes("market") || job.id.includes("morning") || job.id.includes("afternoon") || job.id.includes("late") || job.id.includes("pre_") || job.id.includes("post_") || job.id.includes("close")),
|
||||
review: jobs.filter((job) => job.id.includes("watchlist") || job.id.includes("strategy")),
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
if (user?.role !== "admin") {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6">
|
||||
<p className="text-sm text-text-muted">需要管理员权限</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||
<header className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-text-muted">Task Center</div>
|
||||
<h1 className="mt-2 text-2xl font-bold tracking-tight text-text-primary">任务中心</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-text-secondary">
|
||||
后台新闻采集、选股扫描、跟踪复盘和策略校准任务的运行视图。
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={loadData} className="w-fit rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-xs font-medium text-text-secondary transition-all hover:bg-surface-4 hover:text-text-primary">
|
||||
刷新
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<MetricCard label="调度器" value={data?.scheduler_running ? "运行中" : "停止"} tone={data?.scheduler_running ? "ok" : "error"} />
|
||||
<MetricCard label="扫描状态" value={data?.scan_running ? "扫描中" : "空闲"} tone={data?.scan_running ? "warning" : "default"} />
|
||||
<MetricCard label="任务数量" value={data?.job_count ?? 0} />
|
||||
<MetricCard label="最近错误" value={data?.recent_errors.length ?? 0} tone={(data?.recent_errors.length ?? 0) > 0 ? "error" : "default"} />
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-5 xl:grid-cols-[1fr_380px]">
|
||||
<main className="space-y-5">
|
||||
<JobGroup title="新闻与舆情" jobs={jobGroups.news} loading={loading} />
|
||||
<JobGroup title="选股扫描" jobs={jobGroups.scan} loading={loading} />
|
||||
<JobGroup title="跟踪复盘" jobs={jobGroups.review} loading={loading} />
|
||||
</main>
|
||||
|
||||
<aside className="space-y-5">
|
||||
<section className="glass-card-static overflow-hidden">
|
||||
<div className="border-b border-border-subtle px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-text-primary">最近筛选事件</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{(data?.recent_scan_logs || []).slice(0, 8).map((item, index) => (
|
||||
<div key={`${item.scan_session}-${item.stage}-${index}`} className="px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="truncate text-xs font-semibold text-text-primary">{stageLabel(item.stage)}</span>
|
||||
<StatusPill status={item.status} />
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-text-muted line-clamp-2">{item.summary || item.scan_session}</div>
|
||||
<div className="mt-2 font-mono text-[10px] tabular-nums text-text-muted">{formatDateTime(item.created_at)}</div>
|
||||
</div>
|
||||
))}
|
||||
{!data?.recent_scan_logs.length && <div className="p-5 text-sm text-text-muted">暂无扫描事件</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="glass-card-static overflow-hidden">
|
||||
<div className="border-b border-border-subtle px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-text-primary">最近错误</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{(data?.recent_errors || []).slice(0, 6).map((item, index) => (
|
||||
<div key={`${item.source}-${index}`} className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusPill status={item.level} />
|
||||
<span className="truncate text-xs text-text-muted">{item.source}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-text-secondary line-clamp-2">{item.message}</div>
|
||||
<div className="mt-2 font-mono text-[10px] tabular-nums text-text-muted">{formatDateTime(item.created_at)}</div>
|
||||
</div>
|
||||
))}
|
||||
{!data?.recent_errors.length && <div className="p-5 text-sm text-text-muted">暂无错误</div>}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JobGroup({ title, jobs, loading }: { title: string; jobs: TaskCenterResult["jobs"]; loading: boolean }) {
|
||||
return (
|
||||
<section 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">{title}</h2>
|
||||
<span className="text-[10px] text-text-muted">{jobs.length}项</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="space-y-3 p-4">
|
||||
{[1, 2, 3].map((item) => <div key={item} className="h-16 animate-shimmer rounded-xl bg-surface-2" />)}
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="p-6 text-sm text-text-muted">暂无任务</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{jobs.map((job) => (
|
||||
<article key={job.id} className="grid gap-3 px-4 py-4 md:grid-cols-[190px_1fr_180px] md:items-center">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-primary">{jobLabel(job.id)}</div>
|
||||
<div className="mt-1 font-mono text-[10px] text-text-muted">{job.id}</div>
|
||||
</div>
|
||||
<div className="text-xs leading-5 text-text-muted line-clamp-2">{cleanTrigger(job.trigger)}</div>
|
||||
<div className="font-mono text-[10px] tabular-nums text-text-secondary md:text-right">
|
||||
{formatDateTime(job.next_run_time)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, tone = "default" }: { label: string; value: string | number; tone?: "default" | "ok" | "warning" | "error" }) {
|
||||
const color = tone === "ok" ? "text-emerald-400" : tone === "warning" ? "text-amber-400" : tone === "error" ? "text-red-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 truncate text-xl font-bold tabular-nums ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: string }) {
|
||||
const normalized = status.toLowerCase();
|
||||
const className = normalized === "ok"
|
||||
? "border-emerald-500/15 bg-emerald-500/[0.06] text-emerald-400"
|
||||
: normalized === "warning" || normalized === "empty"
|
||||
? "border-amber-500/15 bg-amber-500/[0.07] text-amber-400"
|
||||
: "border-red-500/15 bg-red-500/[0.07] text-red-400";
|
||||
const label = normalized === "ok" ? "正常" : normalized === "empty" ? "空结果" : normalized === "warning" ? "警告" : "异常";
|
||||
return <span className={`w-fit rounded-md border px-2 py-0.5 text-[10px] font-medium ${className}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function jobLabel(id: string) {
|
||||
const map: Record<string, string> = {
|
||||
news_pre_market: "盘前新闻",
|
||||
news_morning: "早盘新闻",
|
||||
news_noon: "午间新闻",
|
||||
news_afternoon: "午后新闻",
|
||||
news_post_market: "盘后新闻",
|
||||
pre_market: "盘前扫描",
|
||||
post_market: "盘后扫描",
|
||||
watchlist_analysis: "自选股分析",
|
||||
strategy_iteration: "策略复盘",
|
||||
};
|
||||
if (map[id]) return map[id];
|
||||
if (id.includes("morning")) return "早盘扫描";
|
||||
if (id.includes("afternoon")) return "午后扫描";
|
||||
if (id.includes("late")) return "尾盘扫描";
|
||||
if (id.includes("close")) return "收盘扫描";
|
||||
return id;
|
||||
}
|
||||
|
||||
function stageLabel(stage: string) {
|
||||
const map: Record<string, string> = {
|
||||
market_temperature: "市场温度",
|
||||
theme_selection: "主线主题",
|
||||
strategy_profile: "策略参数",
|
||||
candidate_recall: "候选召回",
|
||||
realtime_quote: "实时行情",
|
||||
rule_scoring: "规则评分",
|
||||
final_filter: "最终作战池",
|
||||
};
|
||||
return map[stage] || stage;
|
||||
}
|
||||
|
||||
function cleanTrigger(trigger: string) {
|
||||
return trigger.replace("cron[", "").replace("]", "");
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
|
||||
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
|
||||
function DashboardIcon() {
|
||||
return (
|
||||
@ -45,25 +43,6 @@ function RadarIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function StrategyIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 19V5" />
|
||||
<path d="M4 19h16" />
|
||||
<path d="M7 15l3-4 3 2 4-7" />
|
||||
<path d="M17 6h3v3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagnoseIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatIcon() {
|
||||
return (
|
||||
@ -81,58 +60,6 @@ function WatchlistIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function UsersIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 5h16" />
|
||||
<path d="M4 12h10" />
|
||||
<path d="M4 19h7" />
|
||||
<path d="M17 15l2 2 3-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 13c0 5-3.5 8-8 8s-8-3-8-8V5l8-3 8 3v8z" />
|
||||
<path d="M9 12l2 2 4-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TasksIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 6h11" />
|
||||
<path d="M9 12h11" />
|
||||
<path d="M9 18h11" />
|
||||
<path d="M4 6h.01" />
|
||||
<path d="M4 12h.01" />
|
||||
<path d="M4 18h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SideNavItem({ href, icon, label, onNavigate }: { href: string; icon: React.ReactNode; label: string; onNavigate?: () => void }) {
|
||||
const pathname = usePathname();
|
||||
@ -155,8 +82,6 @@ function SideNavItem({ href, icon, label, onNavigate }: { href: string; icon: Re
|
||||
}
|
||||
|
||||
export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<nav className="flex-1 overflow-y-auto px-2 sm:px-3 py-4 sm:py-5 space-y-1">
|
||||
<SideNavItem href="/dashboard" icon={<DashboardIcon />} label="今日作战" onNavigate={onNavigate} />
|
||||
@ -165,15 +90,6 @@ export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
||||
<SideNavItem href="/sentiment" icon={<RadarIcon />} label="舆情雷达" onNavigate={onNavigate} />
|
||||
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" onNavigate={onNavigate} />
|
||||
<SideNavItem href="/chat" icon={<ChatIcon />} label="研究助手" onNavigate={onNavigate} />
|
||||
{user?.role === "admin" && (
|
||||
<>
|
||||
<SideNavItem href="/strategy" icon={<StrategyIcon />} label="策略校准" onNavigate={onNavigate} />
|
||||
<SideNavItem href="/ops-logs" icon={<LogsIcon />} label="系统日志" onNavigate={onNavigate} />
|
||||
<SideNavItem href="/data-health" icon={<HealthIcon />} label="数据源健康" onNavigate={onNavigate} />
|
||||
<SideNavItem href="/tasks" icon={<TasksIcon />} label="任务中心" onNavigate={onNavigate} />
|
||||
<SideNavItem href="/settings" icon={<SettingsIcon />} label="管理设置" onNavigate={onNavigate} />
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user