diff --git a/backend/app/__pycache__/main.cpython-313.pyc b/backend/app/__pycache__/main.cpython-313.pyc index a79f47c4..7a6a197d 100644 Binary files a/backend/app/__pycache__/main.cpython-313.pyc and b/backend/app/__pycache__/main.cpython-313.pyc differ diff --git a/backend/app/api/__pycache__/market.cpython-313.pyc b/backend/app/api/__pycache__/market.cpython-313.pyc index 3ed195d9..daac0f36 100644 Binary files a/backend/app/api/__pycache__/market.cpython-313.pyc and b/backend/app/api/__pycache__/market.cpython-313.pyc differ diff --git a/backend/app/api/debug.py b/backend/app/api/debug.py deleted file mode 100644 index 06438180..00000000 --- a/backend/app/api/debug.py +++ /dev/null @@ -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(), - } diff --git a/backend/app/api/market.py b/backend/app/api/market.py index 4590412a..f5dd2c25 100644 --- a/backend/app/api/market.py +++ b/backend/app/api/market.py @@ -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 diff --git a/backend/app/engine/__pycache__/scheduler.cpython-313.pyc b/backend/app/engine/__pycache__/scheduler.cpython-313.pyc index 0ba5ce6c..ffbf7554 100644 Binary files a/backend/app/engine/__pycache__/scheduler.cpython-313.pyc and b/backend/app/engine/__pycache__/scheduler.cpython-313.pyc differ diff --git a/backend/app/engine/__pycache__/screener.cpython-313.pyc b/backend/app/engine/__pycache__/screener.cpython-313.pyc index 6675a90a..ab23607f 100644 Binary files a/backend/app/engine/__pycache__/screener.cpython-313.pyc and b/backend/app/engine/__pycache__/screener.cpython-313.pyc differ diff --git a/backend/app/engine/scheduler.py b/backend/app/engine/scheduler.py index 67bbc3dd..91d6d654 100644 --- a/backend/app/engine/scheduler.py +++ b/backend/app/engine/scheduler.py @@ -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("盘中调度器已配置完成") diff --git a/backend/app/engine/screener.py b/backend/app/engine/screener.py index 58d577b5..d7a12190 100644 --- a/backend/app/engine/screener.py +++ b/backend/app/engine/screener.py @@ -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" diff --git a/backend/app/engine/trigger_monitor.py b/backend/app/engine/trigger_monitor.py new file mode 100644 index 00000000..cc1b5950 --- /dev/null +++ b/backend/app/engine/trigger_monitor.py @@ -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}") diff --git a/backend/app/llm/__pycache__/tool_executor.cpython-313.pyc b/backend/app/llm/__pycache__/tool_executor.cpython-313.pyc index e572912c..66316718 100644 Binary files a/backend/app/llm/__pycache__/tool_executor.cpython-313.pyc and b/backend/app/llm/__pycache__/tool_executor.cpython-313.pyc differ diff --git a/backend/app/llm/strategy_board.py b/backend/app/llm/strategy_board.py deleted file mode 100644 index 7c775a70..00000000 --- a/backend/app/llm/strategy_board.py +++ /dev/null @@ -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 "" diff --git a/backend/app/llm/strategy_config.py b/backend/app/llm/strategy_config.py index 695573dd..45efa7cf 100644 --- a/backend/app/llm/strategy_config.py +++ b/backend/app/llm/strategy_config.py @@ -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 diff --git a/backend/app/llm/strategy_iteration.py b/backend/app/llm/strategy_iteration.py deleted file mode 100644 index 0e86f22c..00000000 --- a/backend/app/llm/strategy_iteration.py +++ /dev/null @@ -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) diff --git a/backend/app/llm/strategy_selector.py b/backend/app/llm/strategy_selector.py index 848fc18d..fbe194f4 100644 --- a/backend/app/llm/strategy_selector.py +++ b/backend/app/llm/strategy_selector.py @@ -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 diff --git a/backend/app/llm/tool_executor.py b/backend/app/llm/tool_executor.py index 03162469..1baa5219 100644 --- a/backend/app/llm/tool_executor.py +++ b/backend/app/llm/tool_executor.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index f1e690d3..f4b8500f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/frontend/src/app/(auth)/data-health/page.tsx b/frontend/src/app/(auth)/data-health/page.tsx deleted file mode 100644 index 0a0a473b..00000000 --- a/frontend/src/app/(auth)/data-health/page.tsx +++ /dev/null @@ -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(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 ( -
-

需要管理员权限

-
- ); - } - - return ( -
-
-
-
Data Health
-

数据源健康

-

- 东方财富、腾讯、Tushare、AKShare 等数据源的错误聚合和本地数据新鲜度。 -

-
-
- - -
-
- -
- - - - -
- -
-
-
-

数据源状态

- {data ? formatDateTime(data.generated_at) : "-"} -
- {loading ? ( -
- {[1, 2, 3, 4].map((item) =>
)} -
- ) : !data?.sources.length ? ( -
暂无数据源记录
- ) : ( -
- {data.sources.map((item) => ( -
-
{sourceLabel(item.source)}
- -
- {item.error_count}错 / {item.warning_count}警 -
-
- {item.last_error || "最近没有记录到异常"} -
-
- {formatDateTime(item.last_seen_at)} -
-
- ))} -
- )} -
- - -
-
- ); -} - -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 ( -
-
{label}
-
{value}
-
- ); -} - -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 {label}; -} - -function sourceLabel(source: string) { - const map: Record = { - eastmoney: "东方财富", - tencent: "腾讯行情", - tushare: "Tushare", - akshare: "AKShare", - sina: "新浪行情", - news: "新闻管道", - tushare_news: "Tushare新闻", - }; - return map[source] || source; -} - -function freshnessLabel(key: string) { - const map: Record = { - 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" }); -} diff --git a/frontend/src/app/(auth)/diagnose/page.tsx b/frontend/src/app/(auth)/diagnose/page.tsx deleted file mode 100644 index 94dd4931..00000000 --- a/frontend/src/app/(auth)/diagnose/page.tsx +++ /dev/null @@ -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([]); - const [showSearch, setShowSearch] = useState(false); - const [loading, setLoading] = useState(false); - const [streamingContent, setStreamingContent] = useState(""); - const [result, setResult] = useState(null); - const [cachedResult, setCachedResult] = useState(null); - const [history, setHistory] = useState<{ ts_code: string; name: string }[]>([]); - const [diagnoseHistory, setDiagnoseHistory] = useState([]); - const [thesis, setThesis] = useState(null); - const [diagnoseMode, setDiagnoseMode] = useState<"entry" | "holding" | "review" | "tracking">("entry"); - const inputRef = useRef(null); - const searchTimer = useRef>(); - const wrapperRef = useRef(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(`/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(`/api/stocks/${code}/thesis`).then(setThesis).catch(() => setThesis(null)); - fetchAPI(`/api/stocks/${code}/diagnose/history`).then(setDiagnoseHistory).catch(() => setDiagnoseHistory([])); - - try { - const token = localStorage.getItem("auth_token"); - const headers: Record = {}; - 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 ( - -
- {/* Header */} -
-
-
- - - - -
-
-

个股诊断

-
-
-
- -
-
-
-
诊断工作台
-
-
-
- {[ - { key: "entry", label: "建仓前诊断" }, - { key: "holding", label: "持仓复核" }, - { key: "review", label: "回撤复盘" }, - { key: "tracking", label: "继续跟踪" }, - ].map((item) => ( - - ))} -
-
-
-
- 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} - /> -
- -
- {showSearch && searchResults.length > 0 && ( -
- {searchResults.map((stock) => ( - - ))} -
- )} -
-
- - {thesis ? ( -
-
诊断依据
-
- 已读取该股最近推荐、跟踪和诊断记录。 -
-
- {(thesis.decision_points ?? []).slice(0, 3).map((point) => ( - - ))} -
-
- ) : ( -
- 输入股票后,这里会展示推荐归档、跟踪状态和最近诊断上下文。 -
- )} -
-
- - {/* Streaming / Loading State */} - {loading && !displayContent && ( -
-
-
正在分析中...
-
收集行情、板块和推荐归档,生成本次会诊结论
-
- )} - - {/* Streaming content */} - {loading && displayContent && ( -
-
- - 正在分析... -
-
- {displayContent} -
-
- )} - - {/* Final Result */} - {!loading && displayContent && ( -
-
-
-
-
结构化结论
-
- -
-
- - - - -
-
-
-
会诊摘要
-
- - - - -
-
-
-
-
- {result?.ts_code || codeParam} - - {cachedResult ? "历史结论" : "分析完成"} - -
-
-
-
-
- )} - - {/* Error */} - {result?.status === "error" && !loading && !displayContent && ( -
-
诊断失败
-
{result.message || "未知错误"}
-
- )} - - {/* Empty state */} - {!result && !loading && !displayContent && history.length === 0 && ( -
-
- - - - -
-
输入股票代码开始诊断
-
- 支持股票代码(如 600683)或名称(如 京投发展) -
-
- {["贵州茅台", "宁德时代", "比亚迪"].map((name) => ( - - ))} -
-
- )} -
- - -
-
-
- ); -} - -function DiagnosisSummaryCard({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -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 "结合下一个交易时段的量价和板块变化再判断。"; -} diff --git a/frontend/src/app/(auth)/ops-logs/page.tsx b/frontend/src/app/(auth)/ops-logs/page.tsx deleted file mode 100644 index e36d10b2..00000000 --- a/frontend/src/app/(auth)/ops-logs/page.tsx +++ /dev/null @@ -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("errors"); - const [days, setDays] = useState(7); - const [sessions, setSessions] = useState([]); - const [selectedSession, setSelectedSession] = useState(""); - const [scanLogs, setScanLogs] = useState([]); - const [observations, setObservations] = useState([]); - const [reasonCounts, setReasonCounts] = useState>({}); - const [scanLoading, setScanLoading] = useState(true); - - const [errors, setErrors] = useState([]); - const [errorsTotal, setErrorsTotal] = useState(0); - const [sources, setSources] = useState([]); - const [levels, setLevels] = useState([]); - const [sourceCounts, setSourceCounts] = useState>({}); - const [levelCounts, setLevelCounts] = useState>({}); - const [source, setSource] = useState(""); - const [level, setLevel] = useState(""); - const [query, setQuery] = useState(""); - const [errorsLoading, setErrorsLoading] = useState(false); - const [expandedErrorId, setExpandedErrorId] = useState(null); - const [systemStatus, setSystemStatus] = useState(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 ( -
-

需要管理员权限

-
- ); - } - - 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 ( -
-
-
-
Ops Center
-

运行日志

-

- 系统错误、接口异常、数据源失败和扫描漏斗集中在这里,方便快速定位异常来源。 -

-
-
- - -
-
- -
- {[ - { key: "errors", label: "系统错误" }, - { key: "funnel", label: "筛选漏斗" }, - ].map((item) => ( - - ))} -
- - {tab === "funnel" ? ( -
- - -
-
- - - 0 ? "danger" : "muted"} /> - -
- -
-
-
-

筛选过程可视化

-

输入、输出、过滤量和关键摘要按筛选顺序归档。

-
- {activeSession?.scan_mode || "-"} -
- - {scanLoading ? ( -
- {[1, 2, 3, 4].map((item) =>
)} -
- ) : sortedLogs.length === 0 ? ( -
- 暂无筛选过程日志 -
- ) : ( -
- {sortedLogs.map((log, index) => ( - - ))} -
- )} -
- -
-
-
-

投研观察

-

候选股的主题、资金、角色、入场信号和最终淘汰原因。

-
- {observations.length} 条记录 -
-
-
-
淘汰原因
-
- {Object.entries(reasonCounts).slice(0, 8).map(([reason, count]) => ( -
- {reason} - {count} -
- ))} - {Object.keys(reasonCounts).length === 0 ? ( -
暂无原因分布
- ) : null} -
-
-
- {observations.length === 0 ? ( -
暂无投研观察记录
- ) : ( -
- {observations.slice(0, 12).map((item) => ( - - ))} -
- )} -
-
-
-
-
- ) : ( -
-
- 0 ? "danger" : "muted"} /> - 0 ? "danger" : "muted"} /> - - -
- -
-
-
-

系统错误日志

-

自动记录应用内 ERROR/CRITICAL 日志,并保留手动上报的数据源和后台任务异常。

-
-
- 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" - /> - - - - - -
-
-
- -
- - -
-
- 错误记录 - 显示 {errors.length} / {errorsTotal} 条 -
- {errorsLoading ? ( -
- {[1, 2, 3].map((item) =>
)} -
- ) : errors.length === 0 ? ( -
暂无错误日志
- ) : ( -
- {errors.map((item) => ( - setExpandedErrorId(expandedErrorId === item.id ? null : item.id)} - /> - ))} -
- )} -
-
-
- )} -
- ); -} - -function ErrorBreakdown({ - title, - items, - active, - onSelect, - labeler, -}: { - title: string; - items: Record; - 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 ( -
-
-

{title}

- {active ? ( - - ) : null} -
-
- {rows.length === 0 ? ( -
暂无分布
- ) : rows.map(([key, count]) => ( - - ))} -
-
- ); -} - -function ErrorRow({ item, expanded, onToggle }: { item: ErrorLog; expanded: boolean; onToggle: () => void }) { - return ( -
- - {expanded ? ( -
-
-
Message
-
{item.message}
-
- {item.detail ? ( -
-              {item.detail}
-            
- ) : ( -
没有堆栈详情
- )} -
- ) : null} -
- ); -} - -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 ( -
-
-
- {index + 1} -
-
-
-

{log.stage_label}

- - {formatDateTime(log.created_at)} -
-

{log.summary || "暂无摘要"}

-
-
-
-
-
-
-
-
- - - -
-
- {detailItems.length > 0 ? ( -
- {detailItems.map((item) => ( - - {item} - - ))} -
- ) : null} -
- ); -} - -function extractDetailItems(log: ScanProcessLog) { - const detail = log.detail || {}; - if (log.stage === "candidate_recall") { - const routes = detail.route_counts as Record | undefined; - return routes ? Object.entries(routes).map(([key, value]) => `${routeLabel(key)} ${value}`) : []; - } - if (log.stage === "rule_scoring") { - const skipped = detail.skipped_counts as Record | undefined; - const signals = detail.signal_counts as Record | 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 | undefined; - const reasons = detail.elimination_reasons as Record | 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 ( -
-
-
{item.name}
-
{item.ts_code}
-
-
{item.final_score.toFixed(1)}
-
-
- {item.theme_name || "未归类"} - {item.stock_role || "候选"} - {signalLabel(item.entry_signal_type)} -
-
{item.elimination_reason || "待确认"}
-
-
- - - - - -
-
- ); -} - -function ScorePill({ label, value }: { label: string; value: number }) { - return ( -
-
{label}
-
{Math.round(value)}
-
- ); -} - -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 ( -
-
{label}
-
{value}
-
{sub}
-
- ); -} - -function TinyMetric({ label, value }: { label: string; value: string | number }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -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 ( - - {statusLabel(status)} - - ); -} - -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 = { - sector_recall: "主线召回", - trend_scan: "趋势召回", - intraday_active: "盘中异动", - realtime_market: "全市场异动", - }; - return map[key] || key; -} - -function skipLabel(key: string) { - const map: Record = { - missing_code: "缺代码", - kline_empty: "K线不足", - stale_kline: "K线过期", - exception: "评分异常", - }; - return map[key] || key; -} - -function signalLabel(key: string) { - const map: Record = { - 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) { - const [source, count] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0] || []; - return source ? `${source} ${count} 条` : "暂无来源"; -} diff --git a/frontend/src/app/(auth)/settings/page.tsx b/frontend/src/app/(auth)/settings/page.tsx deleted file mode 100644 index 3a226b5f..00000000 --- a/frontend/src/app/(auth)/settings/page.tsx +++ /dev/null @@ -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
("access"); - const [users, setUsers] = useState([]); - const [inviteCodes, setInviteCodes] = useState([]); - const [dataStats, setDataStats] = useState(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(null); - const [creatingInvite, setCreatingInvite] = useState(false); - - const [resetResult, setResetResult] = useState<{ email: string; password: string } | null>(null); - const [copiedKey, setCopiedKey] = useState(""); - - const [resetMode, setResetMode] = useState("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 ( -
-

需要管理员权限

-
- ); - } - - 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 ( -
-
-
-
Admin
-

管理设置

-

- 管理注册准入、账号状态和业务数据生命周期。运行日志、数据源健康和任务中心继续保留在侧边栏独立入口。 -

-
-
- {[ - { key: "access", label: "账号准入" }, - { key: "data", label: "数据维护" }, - ].map((item) => ( - - ))} -
-
- - {message ? ( -
- {message} -
- ) : null} - - {loading ? ( -
-
-
-
-
- ) : section === "access" ? ( -
-
- - - - -
- -
-
-
-
-

用户列表

-

账号状态、角色、注册来源和密码重置。

-
- {users.length} 个账号 -
-
- - - - - - - - - - - - - {users.map((item) => ( - - - - - - - - - ))} - -
用户角色状态邀请码创建时间操作
-
{item.email || item.username}
-
#{item.id}
-
{item.role}{item.is_active ? "启用" : "禁用"}{item.invite_code_used || "-"}{formatDateTime(item.created_at)} - {item.id !== currentUser.id ? ( -
- - {item.is_active ? ( - - ) : null} -
- ) : ( - 当前账号 - )} -
-
-
- -
-
-
-

邀请码

-

复制注册链接后,用户打开会自动进入注册并填入邀请码。

-
- -
-
- {inviteCodes.length ? inviteCodes.map((item) => ( - handleToggleInvite(item.id)} - /> - )) : ( -
暂无邀请码
- )} -
-
-
-
- ) : ( -
-
-
-

数据资产

-

这里管理的是系统运行产生的业务数据,不管理用户账号本身。

-
-
- - - - -
-
- 当前推荐记录日期范围:{dataStats?.earliest_date || "-"} 到 {dataStats?.latest_date || "-"} -
-
- -
-

维护动作

-
- {RESET_OPTIONS.map((item) => ( - - ))} -
- - {resetMode === "date_range" ? ( -
- - 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" - /> -
- ) : null} - -
- 将执行:{selectedReset.scope} -
- - {confirmReset ? ( -
-
确认执行这个数据维护动作?
-
- - -
-
- ) : ( - - )} -
-
- )} - - {showInviteDialog ? ( - setShowInviteDialog(false)}> - {createdInviteCode ? ( -
-

邀请码创建成功

-
-
注册链接
-
{buildInviteLink(createdInviteCode)}
-
-
- - -
- -
- ) : ( -
-

新建邀请码

- 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" /> - 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" /> - 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" /> -
- - -
-
- )} -
- ) : null} - - {resetResult ? ( - setResetResult(null)}> -
-

密码已重置

-
-
邮箱{resetResult.email}
-
新密码{resetResult.password}
-
- -
-
- ) : null} -
- ); -} - -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 ( -
-
-
-
- {item.code} - {item.is_active ? "启用" : "停用"} - {exhausted ? 已用完 : null} -
-
{item.description || "无说明"}
-
- -
-
- - - -
-
-
注册链接
-
{link}
-
-
- - -
-
- ); -} - -function DataBucket({ title, description, stats }: { title: string; description: string; stats: Array<[string, number]> }) { - return ( -
-
{title}
-
{description}
-
- {stats.map(([label, value]) => ( - - ))} -
-
- ); -} - -function Metric({ label, value }: { label: string; value: number }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -function MiniStat({ label, value }: { label: string; value: number }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -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 {children}; -} - -function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { - return ( -
-
- ); -} - -function formatDateTime(value: string | null) { - if (!value) return "-"; - return new Date(value).toLocaleString("zh-CN"); -} - -function formatDeletedKey(key: string) { - const labels: Record = { - recommendation_tracking: "跟踪记录", - tracking: "跟踪记录", - recommendations: "推荐记录", - sector_heat: "板块热度", - market_temperature: "市场温度", - stock_diagnoses: "单股诊断", - watchlist_analyses: "自选分析", - error_logs: "错误日志", - scan_process_logs: "扫描日志", - research_observations: "候选观察", - }; - return labels[key] ?? key; -} diff --git a/frontend/src/app/(auth)/strategy/page.tsx b/frontend/src/app/(auth)/strategy/page.tsx deleted file mode 100644 index d0f1c207..00000000 --- a/frontend/src/app/(auth)/strategy/page.tsx +++ /dev/null @@ -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 = { - tighten: "收紧", - promote: "加强", - reduce: "降权", - keep: "保持", - observe: "观察", -}; - -export default function StrategyPage() { - const { user, loading: authLoading } = useAuth(); - const router = useRouter(); - const [iteration, setIteration] = useState(null); - const [performance, setPerformance] = useState(null); - const [configCenter, setConfigCenter] = useState(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("/api/market/strategy-iteration?limit=80").catch(() => null), - fetchAPI("/api/recommendations/performance").catch(() => null), - fetchAPI("/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("/api/market/generate-strategy-iteration?limit=80"); - const configs = await fetchAPI("/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 ( -
-
-
-
- ); - } - - return ( -
-
-

系统校准

-
- -
-
-
配置化自我迭代
-
-
- {actionMessage ? {actionMessage} : null} - -
-
- -
-
-
-
系统当前判断
-

{diagnosis.headline}

-

{diagnosis.detail}

- -
- - -
-
- -
- - = 50 ? "up" : "down"} /> - 0 ? "+" : ""}${(performance?.avg_return ?? 0).toFixed(2)}%`} tone={(performance?.avg_return ?? 0) >= 0 ? "up" : "down"} /> - - - -
-
-
- - {iteration ? ( - <> -
-
-
-
- -

- {iteration.summary} -

-
-
- {new Date(iteration.generated_at).toLocaleString("zh-CN")} -
-
- {iteration.ai_analysis ? ( -
- {iteration.ai_analysis} -
- ) : null} -
- -
- - - -
-
- -
-
- {(iteration.adjustment_suggestions.length - ? iteration.adjustment_suggestions - : [{ target: "推荐系统", action: "observe", reason: "等待更多跟踪样本后再调整策略权重。", confidence: "低" }] - ).slice(0, 6).map((item, index) => ( - - ))} -
-
- -
- - -
- - - -
- -
- {iteration.failure_patterns.length ? ( - iteration.failure_patterns.map((pattern, index) => ( -
- {pattern} -
- )) - ) : ( -
- 暂无明确失效模式。 -
- )} -
-
- - ) : ( -
-
暂无系统校准数据
-
- )} -
- ); -} - -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 ( -
-
{title}
-
- {items.map((item, index) => ( -
- - {item} -
- ))} -
-
- ); -} - -function ConfigCenterPanel({ - configs, - onRollback, -}: { - configs: StrategyConfigCenter | null; - onRollback: (strategyId: string) => void; -}) { - const strategies = configs?.strategies ?? []; - const prompts = configs?.prompts ?? []; - const changes = configs?.changes ?? []; - - return ( -
-
-
- - 下一轮扫描直接读取 -
-
- {strategies.length ? strategies.map((item) => ( - - )) : ( -
暂无配置数据。
- )} -
-
-
Prompt 版本
-
- {prompts.length ? prompts.map((item) => ( - - {item.prompt_key} · v{item.version} - - )) : ( - 暂无 Prompt 配置。 - )} -
-
-
- -
- -
- {changes.length ? changes.slice(0, 6).map((item) => ( - - )) : ( -
暂无变更记录。
- )} -
-
-
- ); -} - -function StrategyConfigCard({ item, onRollback }: { item: StrategyConfigRecord; onRollback: (strategyId: string) => void }) { - const cfg = item.config; - const scoreWeights = cfg.score_weights as Record | undefined; - const weightLabels: Record = { - 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 ( -
-
-
-
{item.strategy_id}
-
v{item.version} · {item.source}
-
- -
-
- - - -
-
- {weightText} -
- {item.change_reason ? ( -
{item.change_reason}
- ) : null} -
- ); -} - -function ConfigChangeRow({ item }: { item: StrategyConfigChange }) { - const diffEntries = Object.entries(item.diff ?? {}).slice(0, 4); - return ( -
-
-
-
{item.strategy_id || item.prompt_key || "配置变更"}
-
- {item.change_type} · v{item.base_version} → v{item.new_version} -
-
- - {item.status} - -
-
{item.reason || "暂无说明"}
- {diffEntries.length ? ( -
- {diffEntries.map(([key, value]) => ( -
- {key}: {formatUnknown(value.from)} → {formatUnknown(value.to)} -
- ))} -
- ) : null} -
- ); -} - -function ReviewWindowsPanel({ windows }: { windows: NonNullable }) { - return ( -
- -
- {windows.length ? windows.map((item) => ( -
-
T+{item.window_days}
-
- - - 0 ? "+" : ""}${item.avg_return.toFixed(2)}%`} /> - - - -
-
- )) : ( -
暂无窗口样本。
- )} -
-
- ); -} - -function AgentPatchPromptPanel({ - prompts, - copiedTitle, - onCopy, -}: { - prompts: AgentPatchPrompt[]; - copiedTitle: string; - onCopy: (item: AgentPatchPrompt) => void; -}) { - return ( -
-
- - 大幅策略改造先人工审查 -
-
- {prompts.length ? prompts.map((item) => ( -
-
-
-
{item.title}
-
{item.evidence}
-
- -
-
- {item.prompt} -
-
- {item.target_files.map((file) => ( - - {file} - - ))} -
-
- )) : ( -
- 当前样本不足或没有集中失效模式,暂不生成代码级改造提示词。 -
- )} -
-
- ); -} - -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 ( -
-
-
-
- 指令 {index + 1} · {verb} -
-
{item.target}
-
- - 置信 {item.confidence} - -
-
{item.reason}
-
- ); -} - -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 ( -
-
{label}
-
{value}
-
- ); -} - -function MetricFact({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -function StatsPanel({ - title, - description, - stats, -}: { - title: string; - description: string; - stats: StrategyStat[]; -}) { - return ( -
- -
{description}
-
- {stats.length ? ( - stats.slice(0, 6).map((stat) => ( -
-
-
{stat.name}
-
0 ? "text-red-400" : stat.avg_return < 0 ? "text-emerald-400" : "text-text-secondary"}`}> - {stat.avg_return > 0 ? "+" : ""}{stat.avg_return.toFixed(2)}% -
-
-
- - - 0 ? "+" : ""}${stat.avg_max_return.toFixed(1)}%`} /> - -
-
- )) - ) : ( -
暂无分组数据
- )} -
-
- ); -} - -function StatCell({ label, value }: { label: string; value: string | number }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -function SectionTitle({ title }: { title: string }) { - return ( -
- {title} -
- ); -} diff --git a/frontend/src/app/(auth)/tasks/page.tsx b/frontend/src/app/(auth)/tasks/page.tsx deleted file mode 100644 index c5fa38aa..00000000 --- a/frontend/src/app/(auth)/tasks/page.tsx +++ /dev/null @@ -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(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 ( -
-

需要管理员权限

-
- ); - } - - return ( -
-
-
-
Task Center
-

任务中心

-

- 后台新闻采集、选股扫描、跟踪复盘和策略校准任务的运行视图。 -

-
- -
- -
- - - - 0 ? "error" : "default"} /> -
- -
-
- - - -
- - -
-
- ); -} - -function JobGroup({ title, jobs, loading }: { title: string; jobs: TaskCenterResult["jobs"]; loading: boolean }) { - return ( -
-
-

{title}

- {jobs.length}项 -
- {loading ? ( -
- {[1, 2, 3].map((item) =>
)} -
- ) : jobs.length === 0 ? ( -
暂无任务
- ) : ( -
- {jobs.map((job) => ( -
-
-
{jobLabel(job.id)}
-
{job.id}
-
-
{cleanTrigger(job.trigger)}
-
- {formatDateTime(job.next_run_time)} -
-
- ))} -
- )} -
- ); -} - -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 ( -
-
{label}
-
{value}
-
- ); -} - -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 {label}; -} - -function jobLabel(id: string) { - const map: Record = { - 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 = { - 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" }); -} diff --git a/frontend/src/components/nav.tsx b/frontend/src/components/nav.tsx index 5108fcb8..e89e37e9 100644 --- a/frontend/src/components/nav.tsx +++ b/frontend/src/components/nav.tsx @@ -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 ( - - - - - - - ); -} - -function DiagnoseIcon() { - return ( - - - - - ); -} function ChatIcon() { return ( @@ -81,58 +60,6 @@ function WatchlistIcon() { ); } -function UsersIcon() { - return ( - - - - - - - ); -} - -function SettingsIcon() { - return ( - - - - - ); -} - -function LogsIcon() { - return ( - - - - - - - ); -} - -function HealthIcon() { - return ( - - - - - ); -} - -function TasksIcon() { - return ( - - - - - - - - - ); -} 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 ( ); }