From 212fda0bbfb03248e91667cdeb596892bab61c8f Mon Sep 17 00:00:00 2001 From: aaron <> Date: Mon, 1 Jun 2026 21:29:26 +0800 Subject: [PATCH] update --- backend/app/__pycache__/main.cpython-313.pyc | Bin 9981 -> 9653 bytes .../api/__pycache__/market.cpython-313.pyc | Bin 10878 -> 1944 bytes backend/app/api/debug.py | 518 ------------- backend/app/api/market.py | 161 +--- .../__pycache__/scheduler.cpython-313.pyc | Bin 8591 -> 7720 bytes .../__pycache__/screener.cpython-313.pyc | Bin 101876 -> 113903 bytes backend/app/engine/scheduler.py | 41 +- backend/app/engine/screener.py | 69 +- backend/app/engine/trigger_monitor.py | 168 +++++ .../__pycache__/tool_executor.cpython-313.pyc | Bin 23445 -> 23709 bytes backend/app/llm/strategy_board.py | 351 --------- backend/app/llm/strategy_config.py | 418 +---------- backend/app/llm/strategy_iteration.py | 576 --------------- backend/app/llm/strategy_selector.py | 135 +--- backend/app/llm/tool_executor.py | 35 +- backend/app/main.py | 6 +- frontend/src/app/(auth)/data-health/page.tsx | 183 ----- frontend/src/app/(auth)/diagnose/page.tsx | 551 -------------- frontend/src/app/(auth)/ops-logs/page.tsx | 686 ------------------ frontend/src/app/(auth)/settings/page.tsx | 613 ---------------- frontend/src/app/(auth)/strategy/page.tsx | 628 ---------------- frontend/src/app/(auth)/tasks/page.tsx | 211 ------ frontend/src/components/nav.tsx | 84 --- 23 files changed, 270 insertions(+), 5164 deletions(-) delete mode 100644 backend/app/api/debug.py create mode 100644 backend/app/engine/trigger_monitor.py delete mode 100644 backend/app/llm/strategy_board.py delete mode 100644 backend/app/llm/strategy_iteration.py delete mode 100644 frontend/src/app/(auth)/data-health/page.tsx delete mode 100644 frontend/src/app/(auth)/diagnose/page.tsx delete mode 100644 frontend/src/app/(auth)/ops-logs/page.tsx delete mode 100644 frontend/src/app/(auth)/settings/page.tsx delete mode 100644 frontend/src/app/(auth)/strategy/page.tsx delete mode 100644 frontend/src/app/(auth)/tasks/page.tsx diff --git a/backend/app/__pycache__/main.cpython-313.pyc b/backend/app/__pycache__/main.cpython-313.pyc index a79f47c412c5ef6b1e8c4d00215cecee2334d44c..7a6a197d8f292312377a1ec36af14ba44f3c8188 100644 GIT binary patch delta 1327 zcmah}O>7fK6rR~x?>aV4^V7sX>)7l6rUa6RfIFWjA3CM#~6|ZWN_$RSkl8&9K zv=@R96|E{2JyilB4qV_yg)FMxDv}C`8wU_zi38$L^@36*hh8f6&AJvjMDj@c?Kkgx z^XBb)Py6qQ``O+tkH!ZJ2P5m-s5+34v0;H-6(=wyvp!C6I5{u?C zZN3wu#EKrIZHtI;Pk@Lxgs68sVQEpq`*=Seuo8Umh$@m+GIw(4**3S_mM3=#iZta< z=J+nUH+LFtMFT-hVRr{#HwS;)L2E~V31x_O32Af-InvD`)XgE>c2H$fAXp6UDWR!k z?z3Ggu&+~L?zeOvu=ahRazpm0(;cxNOH+VNHcBTTp#DjFKyl1AKXv@7QXllXOugkO zmgZM0aKQDY;lnFhuDO+!LT$NHDT%s@1{0TvH-C54oHW=R8%$FgZx-MEoEc$M7yfUt zotzVqDTHjj4==rUlzb1ge3O3GNfJkhq}8v;&(tI#k{^=l!k)Kkg*eIV3O?Bg$=fq1 zqv~YtV?xf+yxiC;=jpJwZ?19&*Cr^}k0>U|ztd5;llQ>2@HlgEH@xv1{q#63(=wT2 zVN#}ekDWzuM;09|&B}DtGrJlGQ#IgXU=%h3KVi|<4BvoT$Zm);65J9B@W}Y+m?C6J zTC`NhB4FV}Tx!J*%ps;rjKhz?F;|TA_+B|< z|7<(qVX#q>$1V=f4ASeKj6rVGj90yrME1Z#Gk*0!?|YJ`O>|iIQsnD?8x7a=7+X&q zoldb0lTOF9jff_B%7ONcF*ZF;HpU|}ee91uHZutKMy}9e^Jr*N(dY2O(_$X#X4YRu zF6_C`7naX8N?$jYwz1`+4A0D|PZp(m9-+lnMNNETvwA~_Z>1nxD3&BtC1?nvOw=WB zNRUo-hG0(^oQ*^rYq-1Kyd1ef2W0J9+G1(0)>stZi1!P rVy|9(MygLq70xF14iC}PHGPZVYl{(OXcPYrslO-SR^mDQkQDy`VdVQ( delta 1650 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 Vo=M=xB#ay$xLF+*S{|9T{{ws6CBFaw 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 0ba5ce6cd04c40c20fdd3cbda8ec593e4e76baa8..ffbf7554bca11ddc6abde88068d077b37f09b453 100644 GIT binary patch delta 2049 zcmaJ?Uu;uV7(ZvXcfD=5+jjjoy0*LB7_0G+{+Pl)xf1KMg z(U>ih_$N!8<3fU<0vZ7m4Uidx2qq*x=-UVn%$xC|+hA{FgT$y0p05Rln0RyV?|%Qz z_nq&2-#KS&%lQs|sj||D=#x0?9XRA1=c}rL7HUqc06~ED05QD>S!Lt>K%s%SnAU?))vlUdEU^vvx`caRrWPy+#Md=b`a8J29 zTIn6q?ZiCVQ1-aL1XN3XWN-v0n9{!=_oGcrE$YXuOg|2=g-+_}?#bgbSH7LOdhzba z)qCg8&W?@WJvl!2(K+F_;iJObg|XQW-kU#iYVOP7xl>=wo*JFGHa2(Z^xW|avmc+G z9luV8&V6=f=7-C_4-buyGU_jYH9odaj_sH7B$-IaMCmR-q5m6U_S(qol`HK+fYsbk ze>sz&g%3U&CZ)5PWIjtsD;-=~esH6)ak;OM2$YrNu;L1Y6@(!Y0KHuzh0NpFPn@(< z6=bWtKw%yYqXnIgZN0;7`jz+G;(fO{-!0Df$F#}5gfQDW&GBOuA687d!WaD)ZIh0c z+uX*R+{V8a7^?hRff|kAhzTFIc+gSo)*aJ%&?j{uIe|UsQ)7f<&Q?bl=0}!_ZZIt6 zZ*WY+G%?~_z=D|$FFvAKw1`%*s*MqCqE6)7bOP$JAoUk(jYYIC*D>J;I47=GZ^kyw znj^B5j*|UyUW%ss3d+7C?rOYCUEyA9+C`m`%>cXA?Nl03lkPR9$P?*aDz%%#O`-YX z?$`s0z3L6O)3g=HqG1>1Z4J#KQWSNAyo*9Wrx{Ywyex&A!=ZV;Xs)_zYFlL>i)Q^L zGh-~3Rj6G{C`4WcB@~B%a=v&xyaMbVHCN*v*a75D&|nI0*_uL4p>?4!eT7h4`-YbG zQ0QO6Fi7YdU^IO?nJMIDMKdb-D9KB4Iya4zGw~8-ex`@iZ)zHvjm5J^(?{cRX&~E2 z!Vn(zB!Q!jKv4;_2`LAFUL!R}JYLgwpk4(qsf`|=Q0y|ZBIgS^Nuiw{FQjBb^3*`2 zpYoF0?}-`wla+OotJ{Cxp-y_fSXY5q{Q|FlGJky-(G@cA@ne2|;*N6-1cfM=br5>nt z1xu3$t2Y5k_LS0iC7*qI*6&c^=j!F!CO_B&FxdbsxLg|3iZ%?a)yI7~~&eKeO04$6H>Hb$Gd zc+zVIdP{Ai0cE0bl9Y+2Wl1V!4%ddpD0xyUR;s)r%N6 REq0bMc-2w`gVz+-^&kA41$zJh delta 2722 zcma)8eM}t36`#4?yW9Kz;Esbcm;*w%^9MN`e}s>-(-_$Wr=<2mlhnlPGq)TE%N?D) z3vrVQoXEkhlVEG&Hc|fwZEV$cRo4|!N~A=pivRFm5h+&fteiiprvt#L^?O9VP?+=>0vY$`7U+cXoA)tr*mEJW^> zx-+?SR>Ly!)2B^Gtq~^RXP;`PS4>Y~7ya7QY+N!SR|TyyN6j|mZNvAi6EB*3IKPX@ z5jVhAfI0v#y=ko;E&|dAPz(@Y5OI-Gpo0uFPkeB&E-eqKb%UuvSxKd3vKg2Hz(xQL zU;{t}KnS1`pb7viB-H?qF^CumoU5RshEa8-7RLHHjEo9FBVk5q9*N}A5^OCIxraHT zmqaHn*ao;4)|qQrHJ6w{TL|F4n!phc@K^VR1L(Wbl&c(Xr=zY1HVd13gMEt$1KlBn z=@y5b_6wG36hfVd#R4xHMU!Z5;98L=^fC=EuX#)Jya(uwJbpg&+04Lb}> z4{L{Qg&v^-FJfDXuZKNY!deI&b`&UtMEn2N={NOR099P^4> zT$Z7m_$P*iUVg&g+kwhdr*w4Ks|UD*MlS8+%s&;yYGHH`-}JT zXWyB9_k+8q&d&b!XS3%o<}bX>Cjb8FTlr5u&YwJS@9Jd!+F1URllMli%L6o1s`NM&N!TSgSae zODPFS&60RlP9B$1S(#C(Oqzw?V1{JpzR%?LbwoJLsH6r`So+rw2 zDW1z_SfZwTQpw@=+!nTC?e6)jZ?PjahSd1Lpd!a=)KEdbH=a(yP_q>{lA8Llta;Zc zle%>ot>jzHizkjpc%mnH6WdZNf|bL|b}TPV5HDlpLhDQ_c(CA&{Dt4&`{-h0Xnj3! zuO^rlgnGaZT=%FUWGB*#?K2d3RkH~HpygU6`guY~q513M=V-RPX|>H8wkf6LQ< z%5+C?j@eGzZrdAg+r49Lr`yi{WIR4&58tu7&ldkYcrG|y7M=0b&)ByunR$zK$;t}t zZC~B(4V51>zTY@LaP8pChFy26YcH98XT5BlesaHQCj8x*>Q=hhTs!PQUIpK`8!fhf zEZLFWzlbU7P4n;kFR>gfG* zNAFBW-xtpQTSEU|g#Lfdn?b|B)R)*{UoUQn;`cp!edr3_>*jy+Oa&vStaz`NpR)0c zZklpoCNky5!0_tmv$kyIrZ(bb@;4aRlnB z-bAbDVtLOsa4g$-(b>SWYUNzv9`Zn?aCug1MA926n)uwe8ym@fCPgcXOYjl;RPh$u z5jG_I0OB;mW?Sj0;!U>JhqE&Mthn5ifU!(*pvYMtB?n=oi->HhxJn=Y047BS5ni(> zEP18+?e)<)Fp9L&hCszG1xQ_Npde=LRx&D++YybDPGCX}Ai$i{U&-fTn4n{UU{x28 z;AL`@f#!WQ=x9+%%@Y>={nX6yL_+Gz93xR!0WTVXYDBgJKyWUn5>-mdpML?yF9PU~ zy+iGerzN$!S5D*R_WN8>%sUtWMgJlI{jcPW|lI-DQe;JWifT=&fE}gk%yVn@& zL;CMv7{5Zxf~9oNh7b3_KZ9mw?{qqqPHKYMo69CLL+MqOie}9t)58+GI4-oVV eVGB;-g%TfD@Iu`lyltU{Eq;8_&f!hExBdeWT)*-F diff --git a/backend/app/engine/__pycache__/screener.cpython-313.pyc b/backend/app/engine/__pycache__/screener.cpython-313.pyc index 6675a90a1fc37797f475d4f425ae4922849108d1..ab23607f1f42a6660556ae351bdddfe77bd10bf8 100644 GIT binary patch delta 40579 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 eN%U9|ke{W 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 e572912c49528360d3a0287191ba51f130e8382f..6631671866c7b5c4eb9aa4ced40d0e4af8334101 100644 GIT binary patch delta 2534 zcmZ`)Yfu~472d1IB1;GX!XSh&5|4>xK!7oEO-%g6Nt+sMgZzx6S)>K4NW0>$Fo7mT z!D*AYaUfh$9<~E+N`f=fDXu1olh~8sOq%ITJA*?yc6U7PIJF@Dk>L2%xM`=oXN64D z);ptb&;8Eho;~;6qgUQR$KOT9n?|Faq0hNzbANR6lyN@IkW5#H=DYGD`yV`l_b(B9v#RCiy$BU z^`*Lz|=d&^#IpZoPM z=3W_}JN))+;26vIdb*vvD?s**Pc5v*Yj@dr_tHy1U9UsOgl?q_Sl)XVAQN|L!-*BwRK}upp-d{6T-%b5Ccymyf^&dE7wv!B8nV zmGeTzTvLEwa5Z#rVef&3#nP}C<^rDo`n)X|`qF8XGdXhDlGiO8;!!-I04K->C<#w% z*p7L;3wEMcgTZ@XJ9U5;@b_T324EAwS_;l3N?BzV^HO^tEUSWHSwnY~WL+Y28DUw! zn60c;@|2T8TxDlK4nh|v;D=O@meEGN#zdHyX2TWoFN>x7q9Luf`i1K0 z%m*gYXAC8g%@<8ed%IrfnzokAm`X30Dksw;k6y`j%%p7`X&gdt>_4%8vh{q?hC#=0 zW8??ZX&e7+vCSl}AJOzxykR_HoLqL^zOHxsuqIMFoxJ`I%T!bjHH``*!fAB5pr~)- z;A8!dO;lWdz}eR{80-&DbhlkDs~F0^U@iXCT0GfyG21a}8@7!#PaK%ZUY%HplXxb( z`n#)3w!AU@(fUhS)}boWb;(gU*7#cX*Gmm1HuA_V3zMAMTYb2CKpbnC$#k7||6$X4 z!?tP7wi`|+rTp#LnCB`lQ#{6 z=LiH+`I_-4dDWSV-in@awyINyKy?(L0N@x|U0Q^Wll`T8mmdcQQ;_r1Q9&n^UuX`9 z9rzTO#qTZtCCJ0%FQwV3;~*~H$k&O^Rb^K8eG@dJ6lCPV17w@ai9U*Uxy))bL|&?> zDFqcC15l#s7oviLsfF$X{Bbk-_EwmY^`%|lOP%JvM>Q)ll1?s0r z|5|Zbk79)RgMPk4*3r^?Tm3={&ZBf4evahTyo{bCr)oB%r0AWRkJ@;rKN1WnLs1Ih&xo{T75XwdvBj=KLGt^$ z60I^{iMIZs@~^>3q~J8huY_{39Ke1F@D;!wfO&xHBvfy4K#83!R;(0z2!KNXO1w65 zvOX6bh<;N4wiZp2pSWv_?tt=d0N()I1^7F_w*dbD_$NcYaF3_oqB5P`v`X}@HTzre zZ8Ex(b}~7;Gmix&B|hJ|0<{t2u0qrqUAgPJ3K>bk-bjiCZAW93w-|U|e~_Hod-dPX CmfQ*e delta 2288 zcmZvceQZ-z6u^7iuKVb^b?eyHb>D1*4(Nn2N0|eKu?3^BS=hiCo7dO7^0KzCx$kwe zQP~2biDsGOM#mDNLqq|iZmAzzjL08ijQ+v2Mw=H+Fak3F5OA9be&6#38zVI7ulJsF z?m4ITo^y^)qE|jbrWundO~XF3Ju`whZn~CbG_&17_MK_h(!RocgG=YqI}wc*+UXgc zk&YMU8IxUxIxU>iXw-emUHjC#_GwG^^sIi~Px?}MmZ?T_+pkOG%FwIcvoe=R&9|gk z_mpNYky_?$jXC!%_=SA4mbr2l;^a9@K`mWlEA8tv&i^HiX1BHu&97a$&h*<-SH4qM zp-IuSpdC5&x-Mk5?jUdrcBJHZ!h+xHmSn=o*cWkoM2>iynZhUpd4Jf8-F|;SNn>iZ z^fDVoqnH95>Bh2~V4o1cN?IQgq;9t#_h7$b%b;CfMp`^l9KMjLC~`(o`{m99}y!wQ#H0m62PF3DNp^e)bCbmqpP1!cz6*IpmnmUp?>KI!Q z&);~%mXT@bZ<#HoWh<=KGL32DO$l23fVs(z4x^?jJ#ETqvd0GVQ&G-oL@Z3F7;n|4 zi~}M1y0wOO<(AWn*76F)K(G|{%ZfH2FTLf}aLWKstfHU?Rrz2a0PF)SKA!q+NhC|v z({nZl>W*Er4Il?*5+!q?oYhj4lr)}Twdy#T^;0r_O6@1oN{8=LzUHe5jd#p#t|iL zzJocB9}}K_TDctUkNsTvo6f9u%r5p<^j;!{$V>F9y&fH-rPYNfN*k+j<}q*=1xH$M z7db)4tBcTB>_T<7&U_S9qk!drp@e50%USC$R2iLq!O7`NdxH0hp;B zW+HFG8k{Q@AG45Tk?&>$BZwoa>1j|ezS0?LtEsc7xx5RS6L1i@$q~xe*wNY8K#fI* zj?&}n8rOh|ya7-nrQH!Jb1@RTLV+izsj+r>`8&+moJHrenrsGp@L8^z*-su96G z0`65(*fi$5g`kg=F!2uZB3-v>2u0{;o0^a|mb&@8;lTvhCrMln_S(zHAUIMV`(;?| z2c==YNXVD4$$$-vFy1fVpiI7^8O@tYhQRnR;2{77s8b9YfqX~1n|GP#O9=Mhh#kDp ze4wtEE!FYPe7U^>=L?DwyPH?RVi<4)3=AP620}6vyc8k{`h3fJbT#&IOKA%7(;s%O zHY*u^At1=^aLCPz;h;=StqsB-a6ZVuM)+c9 Mey^{llbx6T2bma|p8x;= 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 ( ); }