1
This commit is contained in:
parent
767c80d387
commit
36666e0352
@ -177,6 +177,10 @@ def calculate_market_temperature(trade_date: str = None) -> MarketTemperature:
|
|||||||
broken_rate=round(broken_rate, 1),
|
broken_rate=round(broken_rate, 1),
|
||||||
index_above_ma20=index_above_ma20,
|
index_above_ma20=index_above_ma20,
|
||||||
temperature=round(temperature, 1),
|
temperature=round(temperature, 1),
|
||||||
|
source="tushare_daily",
|
||||||
|
data_status="fresh",
|
||||||
|
source_detail=f"daily={trade_date}",
|
||||||
|
limit_counts_reliable=not limit_df.empty,
|
||||||
)
|
)
|
||||||
logger.info(f"市场温度 {trade_date}: {temperature:.1f} (涨{up_count}/跌{down_count}, 涨停{limit_up_count}, 连板{max_streak})")
|
logger.info(f"市场温度 {trade_date}: {temperature:.1f} (涨{up_count}/跌{down_count}, 涨停{limit_up_count}, 连板{max_streak})")
|
||||||
return result
|
return result
|
||||||
@ -196,18 +200,25 @@ async def build_realtime_market_temperature(
|
|||||||
pct = sh_index.get("pct_chg", 0) if sh_index else 0
|
pct = sh_index.get("pct_chg", 0) if sh_index else 0
|
||||||
|
|
||||||
ratio = breadth.up_count / max(breadth.down_count, 1)
|
ratio = breadth.up_count / max(breadth.down_count, 1)
|
||||||
# 实时口径里,把涨停/跌停降级为可选增强项。
|
# 实时口径里,涨跌停池可靠时用专门池;池接口失败时用全市场行情阈值估算。
|
||||||
# 主判断只依赖上涨/下跌家数与指数方向,避免涨停池接口不稳影响系统决策。
|
# 估算口径权重略低,并在研究报告中标记为降级,避免悄悄污染判断。
|
||||||
temp_from_ratio = min(ratio / 3.0 * 40, 40)
|
temp_from_ratio = min(ratio / 3.0 * 40, 40)
|
||||||
temp_from_limit_up = min(breadth.limit_up_count / 3, 10) if breadth.limit_counts_reliable else 0
|
temp_from_limit_up = (
|
||||||
|
min(breadth.limit_up_count / 3, 10)
|
||||||
|
if breadth.limit_counts_reliable
|
||||||
|
else min(breadth.limit_up_count / 5, 8)
|
||||||
|
)
|
||||||
temp_from_index = min(max(pct * 10 + 15, 0), 30)
|
temp_from_index = min(max(pct * 10 + 15, 0), 30)
|
||||||
|
|
||||||
baseline.trade_date = breadth.trade_date
|
baseline.trade_date = breadth.trade_date
|
||||||
baseline.up_count = breadth.up_count
|
baseline.up_count = breadth.up_count
|
||||||
baseline.down_count = breadth.down_count
|
baseline.down_count = breadth.down_count
|
||||||
if breadth.limit_counts_reliable:
|
|
||||||
baseline.limit_up_count = breadth.limit_up_count
|
baseline.limit_up_count = breadth.limit_up_count
|
||||||
baseline.limit_down_count = breadth.limit_down_count
|
baseline.limit_down_count = breadth.limit_down_count
|
||||||
baseline.temperature = round(min(max(temp_from_ratio + temp_from_limit_up + temp_from_index + 20, 0), 100), 1)
|
baseline.temperature = round(min(max(temp_from_ratio + temp_from_limit_up + temp_from_index + 20, 0), 100), 1)
|
||||||
baseline.index_above_ma20 = pct > 0 if sh_index else baseline.index_above_ma20
|
baseline.index_above_ma20 = pct > 0 if sh_index else baseline.index_above_ma20
|
||||||
|
baseline.source = breadth.source
|
||||||
|
baseline.source_detail = breadth.source
|
||||||
|
baseline.limit_counts_reliable = breadth.limit_counts_reliable
|
||||||
|
baseline.data_status = "fresh" if breadth.limit_counts_reliable else "estimated"
|
||||||
return baseline, True
|
return baseline, True
|
||||||
|
|||||||
289
backend/app/api/debug.py
Normal file
289
backend/app/api/debug.py
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
"""Debug and operations visibility APIs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/debug", tags=["debug"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system")
|
||||||
|
async def get_system_status(_admin: dict = Depends(get_current_admin)):
|
||||||
|
async with get_db() as db:
|
||||||
|
errors = await db.execute(text("SELECT COUNT(*) FROM error_logs WHERE created_at >= :start"), {"start": _days_ago(1)})
|
||||||
|
scans = await db.execute(text("SELECT COUNT(*) FROM scan_process_logs WHERE created_at >= :start"), {"start": _days_ago(1)})
|
||||||
|
latest_scan = await db.execute(text("SELECT * FROM scan_process_logs ORDER BY created_at DESC, id DESC LIMIT 1"))
|
||||||
|
latest_error = await db.execute(text("SELECT * FROM error_logs ORDER BY created_at DESC, id DESC LIMIT 1"))
|
||||||
|
error_count = errors.scalar() or 0
|
||||||
|
scan_count = scans.scalar() or 0
|
||||||
|
return {
|
||||||
|
"status": "warning" if error_count else "ok",
|
||||||
|
"generated_at": datetime.now().isoformat(),
|
||||||
|
"db_size_mb": _db_size_mb(),
|
||||||
|
"today_error_count": error_count,
|
||||||
|
"today_scan_log_count": scan_count,
|
||||||
|
"latest_scan": _row(latest_scan.fetchone()),
|
||||||
|
"latest_error": _row(latest_error.fetchone()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/errors")
|
||||||
|
async def get_error_logs(
|
||||||
|
limit: int = 50,
|
||||||
|
source: str | None = None,
|
||||||
|
level: str | None = None,
|
||||||
|
days: int = 7,
|
||||||
|
q: str | None = None,
|
||||||
|
_admin: dict = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
clauses = ["created_at >= :start"]
|
||||||
|
params: dict = {"start": _days_ago(days), "limit": min(max(limit, 1), 200)}
|
||||||
|
if source:
|
||||||
|
clauses.append("source = :source")
|
||||||
|
params["source"] = source
|
||||||
|
if level:
|
||||||
|
clauses.append("level = :level")
|
||||||
|
params["level"] = level
|
||||||
|
if q:
|
||||||
|
clauses.append("(message LIKE :q OR detail LIKE :q)")
|
||||||
|
params["q"] = f"%{q}%"
|
||||||
|
where = " AND ".join(clauses)
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
f"SELECT * FROM error_logs WHERE {where} "
|
||||||
|
"ORDER BY created_at DESC, id DESC LIMIT :limit"
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
count = await db.execute(text(f"SELECT COUNT(*) FROM error_logs WHERE {where}"), params)
|
||||||
|
rows = [_row(row) for row in result.fetchall()]
|
||||||
|
sources = sorted({row.get("source") for row in rows if row.get("source")})
|
||||||
|
levels = sorted({row.get("level") for row in rows if row.get("level")})
|
||||||
|
return {
|
||||||
|
"total": count.scalar() or 0,
|
||||||
|
"errors": rows,
|
||||||
|
"logs": rows,
|
||||||
|
"sources": sources,
|
||||||
|
"levels": levels,
|
||||||
|
"source_counts": _count_by(rows, "source"),
|
||||||
|
"level_counts": _count_by(rows, "level"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/errors")
|
||||||
|
async def clear_error_logs(days: int = 30, _admin: dict = Depends(get_current_admin)):
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text("DELETE FROM error_logs WHERE created_at < :start"),
|
||||||
|
{"start": _days_ago(days)},
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "ok", "deleted": result.rowcount or 0}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scan-sessions")
|
||||||
|
async def get_scan_sessions(days: int = 7, limit: int = 30, _admin: dict = Depends(get_current_admin)):
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT scan_session, MAX(scan_mode) AS scan_mode, MAX(created_at) AS created_at, "
|
||||||
|
"COUNT(*) AS stage_count, MAX(status) AS status, MAX(input_count) AS input_count, "
|
||||||
|
"MAX(output_count) AS final_count, SUM(filtered_count) AS drop_count, MAX(summary) AS last_summary "
|
||||||
|
"FROM scan_process_logs WHERE created_at >= :start "
|
||||||
|
"GROUP BY scan_session ORDER BY MAX(created_at) DESC LIMIT :limit"
|
||||||
|
),
|
||||||
|
{"start": _days_ago(days), "limit": min(max(limit, 1), 100)},
|
||||||
|
)
|
||||||
|
return {"sessions": [_row(row) for row in result.fetchall()]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scan-logs")
|
||||||
|
async def get_scan_logs(scan_session: str | None = None, days: int = 7, limit: int = 120, _admin: dict = Depends(get_current_admin)):
|
||||||
|
async with get_db() as db:
|
||||||
|
if not scan_session:
|
||||||
|
latest = await db.execute(text("SELECT scan_session FROM scan_process_logs ORDER BY created_at DESC, id DESC LIMIT 1"))
|
||||||
|
row = latest.fetchone()
|
||||||
|
scan_session = row._mapping["scan_session"] if row else None
|
||||||
|
if not scan_session:
|
||||||
|
return {"scan_session": None, "logs": []}
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT * FROM scan_process_logs WHERE scan_session = :session AND created_at >= :start "
|
||||||
|
"ORDER BY created_at ASC, id ASC LIMIT :limit"
|
||||||
|
),
|
||||||
|
{"session": scan_session, "start": _days_ago(days), "limit": min(max(limit, 1), 300)},
|
||||||
|
)
|
||||||
|
return {"scan_session": scan_session, "logs": [_scan_row(row) for row in result.fetchall()]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks")
|
||||||
|
async def get_task_center(_admin: dict = Depends(get_current_admin)):
|
||||||
|
from app.engine import scheduler as scheduler_module
|
||||||
|
from app.engine.recommender import _scan_running
|
||||||
|
|
||||||
|
scheduler = getattr(scheduler_module, "scheduler", None)
|
||||||
|
jobs = []
|
||||||
|
if scheduler:
|
||||||
|
for job in scheduler.get_jobs():
|
||||||
|
jobs.append({
|
||||||
|
"id": job.id,
|
||||||
|
"name": job.name,
|
||||||
|
"next_run_time": str(job.next_run_time or ""),
|
||||||
|
"trigger": str(job.trigger),
|
||||||
|
})
|
||||||
|
async with get_db() as db:
|
||||||
|
scan_logs = await db.execute(
|
||||||
|
text("SELECT * FROM scan_process_logs ORDER BY created_at DESC, id DESC LIMIT 12")
|
||||||
|
)
|
||||||
|
errors = await db.execute(
|
||||||
|
text("SELECT source, level, message, created_at FROM error_logs ORDER BY created_at DESC, id DESC LIMIT 8")
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"scheduler_running": bool(scheduler and scheduler.running),
|
||||||
|
"scan_running": bool(_scan_running),
|
||||||
|
"scan_locked": bool(_scan_running),
|
||||||
|
"job_count": len(jobs),
|
||||||
|
"jobs": jobs,
|
||||||
|
"recent_scan_logs": [_row(row) for row in scan_logs.fetchall()],
|
||||||
|
"recent_errors": [_row(row) for row in errors.fetchall()],
|
||||||
|
"generated_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/data-source-health")
|
||||||
|
async def get_data_source_health(days: int = 7, _admin: dict = Depends(get_current_admin)):
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT source, "
|
||||||
|
"SUM(CASE WHEN level = 'error' THEN 1 ELSE 0 END) AS error_count, "
|
||||||
|
"SUM(CASE WHEN level = 'warning' THEN 1 ELSE 0 END) AS warning_count, "
|
||||||
|
"MAX(message) AS last_error, MAX(created_at) AS last_seen_at "
|
||||||
|
"FROM error_logs WHERE created_at >= :start GROUP BY source ORDER BY error_count DESC, warning_count DESC"
|
||||||
|
),
|
||||||
|
{"start": _days_ago(days)},
|
||||||
|
)
|
||||||
|
freshness_rows = {
|
||||||
|
"market_temperature": await _latest_row(db, "market_temperature", "市场温度", "created_at", "trade_date"),
|
||||||
|
"sector_heat": await _latest_row(db, "sector_heat", "板块主线", "created_at", "trade_date"),
|
||||||
|
"recommendations": await _latest_row(db, "recommendations", "机会清单", "created_at", "scan_session"),
|
||||||
|
"research_reports": await _latest_row(db, "research_reports", "研究报告", "created_at", "trade_date"),
|
||||||
|
"news_items": await _latest_row(db, "news_items", "消息催化", "created_at", "published_at"),
|
||||||
|
"scan_process_logs": await _latest_row(db, "scan_process_logs", "扫描记录", "created_at", "scan_session"),
|
||||||
|
}
|
||||||
|
sources = []
|
||||||
|
for row in result.fetchall():
|
||||||
|
item = _row(row)
|
||||||
|
sources.append({
|
||||||
|
**item,
|
||||||
|
"status": "error" if item.get("error_count", 0) else "warning" if item.get("warning_count", 0) else "ok",
|
||||||
|
})
|
||||||
|
freshness = {key: _freshness_status(value) for key, value in freshness_rows.items()}
|
||||||
|
return {"days": days, "sources": sources, "freshness": freshness, "generated_at": datetime.now().isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
def _days_ago(days: int) -> str:
|
||||||
|
return (datetime.now() - timedelta(days=max(1, days))).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def _db_size_mb() -> float:
|
||||||
|
path = os.environ.get("ASTOCK_DATABASE_URL", "").replace("sqlite:///", "") or "./astock.db"
|
||||||
|
if not os.path.exists(path):
|
||||||
|
path = "./data/astock.db"
|
||||||
|
try:
|
||||||
|
return round(os.path.getsize(path) / 1024 / 1024, 2)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _row(row) -> dict:
|
||||||
|
if not row:
|
||||||
|
return {}
|
||||||
|
return {key: _json_safe(value) for key, value in dict(row._mapping).items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_row(row) -> dict:
|
||||||
|
data = _row(row)
|
||||||
|
raw = data.pop("detail_json", "{}")
|
||||||
|
try:
|
||||||
|
data["detail"] = json.loads(raw or "{}")
|
||||||
|
except Exception:
|
||||||
|
data["detail"] = {}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def _latest_row(db, table: str, label: str, time_column: str, ref_column: str) -> dict:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
f"SELECT MAX({time_column}) AS last_success_at, MAX({ref_column}) AS last_reference, COUNT(*) AS total "
|
||||||
|
f"FROM {table}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = _row(result.fetchone())
|
||||||
|
row["label"] = label
|
||||||
|
row["table"] = table
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _freshness_status(item: dict) -> dict:
|
||||||
|
last_success = item.get("last_success_at") or ""
|
||||||
|
status = "missing"
|
||||||
|
age_minutes = None
|
||||||
|
if last_success:
|
||||||
|
parsed = _parse_datetime(last_success)
|
||||||
|
if parsed:
|
||||||
|
age_minutes = round((datetime.now() - parsed).total_seconds() / 60, 1)
|
||||||
|
status = "ok" if age_minutes <= 360 else "stale"
|
||||||
|
return {
|
||||||
|
**item,
|
||||||
|
"status": status,
|
||||||
|
"age_minutes": age_minutes,
|
||||||
|
"message": _freshness_message(item.get("label", ""), status, age_minutes),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _freshness_message(label: str, status: str, age_minutes: float | None) -> str:
|
||||||
|
if status == "missing":
|
||||||
|
return f"{label}暂无成功记录"
|
||||||
|
if age_minutes is None:
|
||||||
|
return f"{label}时间无法识别"
|
||||||
|
if age_minutes <= 60:
|
||||||
|
return f"{label}刚更新"
|
||||||
|
if age_minutes <= 360:
|
||||||
|
return f"{label}{round(age_minutes / 60, 1)}小时前更新"
|
||||||
|
return f"{label}超过6小时未更新"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_datetime(value: str):
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(str(value).split(".")[0], fmt)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _json_safe(value):
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.isoformat()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _count_by(rows: list[dict], key: str) -> dict:
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for row in rows:
|
||||||
|
value = row.get(key)
|
||||||
|
if value:
|
||||||
|
counts[str(value)] = counts.get(str(value), 0) + 1
|
||||||
|
return counts
|
||||||
264
backend/app/api/research.py
Normal file
264
backend/app/api/research.py
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
"""Research report APIs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.core.deps import get_current_admin
|
||||||
|
from app.db import tables
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.research.industry_chain_agent import ensure_theme_knowledge_seeded, load_theme_chain_library
|
||||||
|
from app.research.report_agent import load_latest_research_report
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/research", tags=["research"])
|
||||||
|
|
||||||
|
|
||||||
|
class ThemeKnowledgeUpdate(BaseModel):
|
||||||
|
theme_name: str = Field(min_length=1)
|
||||||
|
aliases: list[str] = Field(default_factory=list)
|
||||||
|
logic: str = ""
|
||||||
|
lifecycle_status: str = "观察期"
|
||||||
|
stage: str = "mid"
|
||||||
|
chain_nodes: list[str] = Field(default_factory=list)
|
||||||
|
chain_items: list[dict] = Field(default_factory=list)
|
||||||
|
is_active: bool = True
|
||||||
|
sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/today")
|
||||||
|
async def get_today_research():
|
||||||
|
report = await load_latest_research_report()
|
||||||
|
if report:
|
||||||
|
return report
|
||||||
|
from app.engine.recommender import get_latest_recommendations
|
||||||
|
from app.research.report_agent import build_research_report_async
|
||||||
|
|
||||||
|
latest = await get_latest_recommendations()
|
||||||
|
latest_scan = latest.get("latest_scan") or {}
|
||||||
|
if latest_scan:
|
||||||
|
return await build_research_report_async(latest, latest_scan.get("scan_session") or "latest")
|
||||||
|
return {
|
||||||
|
"trade_date": datetime.now().strftime("%Y%m%d"),
|
||||||
|
"scan_session": "",
|
||||||
|
"scanned_at": "",
|
||||||
|
"market_view": {"regime": "unknown", "confidence": 0, "summary": "暂无研究报告。"},
|
||||||
|
"theme_views": [],
|
||||||
|
"industry_chain_map": [],
|
||||||
|
"opportunity_cards": [],
|
||||||
|
"risk_alerts": [],
|
||||||
|
"no_trade_reason": {"has_scan": False, "reason": "暂无完成扫描。"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def get_research_history(days: int = 14):
|
||||||
|
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT scan_session, trade_date, market_summary, theme_summary, no_trade_reason, report_json, created_at "
|
||||||
|
"FROM research_reports WHERE created_at >= :start "
|
||||||
|
"ORDER BY created_at DESC, id DESC LIMIT 60"
|
||||||
|
),
|
||||||
|
{"start": start},
|
||||||
|
)
|
||||||
|
rows = []
|
||||||
|
for row in result.fetchall():
|
||||||
|
r = row._mapping
|
||||||
|
rows.append({
|
||||||
|
"scan_session": r["scan_session"],
|
||||||
|
"trade_date": r["trade_date"],
|
||||||
|
"market_summary": r["market_summary"] or "",
|
||||||
|
"theme_summary": r["theme_summary"] or "",
|
||||||
|
"no_trade_reason": _safe_json(r["no_trade_reason"]),
|
||||||
|
"created_at": str(r["created_at"] or ""),
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/themes")
|
||||||
|
async def get_research_themes():
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT * FROM theme_maps "
|
||||||
|
"WHERE scan_session = (SELECT scan_session FROM research_reports ORDER BY created_at DESC, id DESC LIMIT 1) "
|
||||||
|
"ORDER BY heat_score DESC"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [dict(row._mapping) for row in result.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/opportunities")
|
||||||
|
async def get_research_opportunities():
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT * FROM opportunity_cards "
|
||||||
|
"WHERE scan_session = (SELECT scan_session FROM research_reports ORDER BY created_at DESC, id DESC LIMIT 1) "
|
||||||
|
"ORDER BY score DESC"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [dict(row._mapping) for row in result.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/risks")
|
||||||
|
async def get_research_risks():
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT * FROM risk_events "
|
||||||
|
"WHERE scan_session = (SELECT scan_session FROM research_reports ORDER BY created_at DESC, id DESC LIMIT 1) "
|
||||||
|
"ORDER BY reject DESC, severity DESC, id DESC"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [dict(row._mapping) for row in result.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/review")
|
||||||
|
async def get_research_review(days: int = 60):
|
||||||
|
from app.research.review_agent import build_research_review
|
||||||
|
|
||||||
|
return await build_research_review(days=days)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/theme-knowledge")
|
||||||
|
async def get_theme_knowledge():
|
||||||
|
return await load_theme_chain_library()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/theme-knowledge/{theme_name}")
|
||||||
|
async def update_theme_knowledge(
|
||||||
|
theme_name: str,
|
||||||
|
payload: ThemeKnowledgeUpdate,
|
||||||
|
_admin: dict = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
await ensure_theme_knowledge_seeded()
|
||||||
|
normalized_name = payload.theme_name.strip() or theme_name.strip()
|
||||||
|
if not normalized_name:
|
||||||
|
raise HTTPException(status_code=400, detail="主题名称不能为空")
|
||||||
|
|
||||||
|
chain_nodes = [node.strip() for node in payload.chain_nodes if node.strip()]
|
||||||
|
if payload.chain_items:
|
||||||
|
chain_nodes = [str(item.get("chain_node") or "").strip() for item in payload.chain_items if str(item.get("chain_node") or "").strip()]
|
||||||
|
if not chain_nodes:
|
||||||
|
raise HTTPException(status_code=400, detail="至少需要一个产业链环节")
|
||||||
|
|
||||||
|
async with get_db() as db:
|
||||||
|
existing = await db.execute(
|
||||||
|
text("SELECT id FROM theme_knowledge WHERE theme_name = :theme_name LIMIT 1"),
|
||||||
|
{"theme_name": theme_name},
|
||||||
|
)
|
||||||
|
row = existing.fetchone()
|
||||||
|
values = {
|
||||||
|
"theme_name": normalized_name,
|
||||||
|
"aliases_json": json.dumps([item.strip() for item in payload.aliases if item.strip()], ensure_ascii=False),
|
||||||
|
"logic_summary": payload.logic.strip(),
|
||||||
|
"lifecycle_status": payload.lifecycle_status.strip() or "观察期",
|
||||||
|
"stage": payload.stage.strip() or "mid",
|
||||||
|
"is_active": bool(payload.is_active),
|
||||||
|
"sort_order": int(payload.sort_order or 0),
|
||||||
|
}
|
||||||
|
if row:
|
||||||
|
await db.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE theme_knowledge SET theme_name = :theme_name, aliases_json = :aliases_json, "
|
||||||
|
"logic_summary = :logic_summary, lifecycle_status = :lifecycle_status, stage = :stage, "
|
||||||
|
"is_active = :is_active, sort_order = :sort_order, updated_at = CURRENT_TIMESTAMP "
|
||||||
|
"WHERE id = :id"
|
||||||
|
),
|
||||||
|
{**values, "id": row._mapping["id"]},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await db.execute(tables.theme_knowledge_table.insert().values(**values))
|
||||||
|
|
||||||
|
await db.execute(text("DELETE FROM theme_chain_knowledge WHERE theme_name = :theme_name"), {"theme_name": theme_name})
|
||||||
|
if normalized_name != theme_name:
|
||||||
|
await db.execute(text("DELETE FROM theme_chain_knowledge WHERE theme_name = :theme_name"), {"theme_name": normalized_name})
|
||||||
|
await db.execute(
|
||||||
|
tables.theme_chain_knowledge_table.insert(),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"theme_name": normalized_name,
|
||||||
|
"chain_node": item["chain_node"],
|
||||||
|
"related_stocks": json.dumps(item.get("related_stocks", []), ensure_ascii=False, default=str),
|
||||||
|
"leader_stocks": json.dumps(item.get("leader_stocks", []), ensure_ascii=False, default=str),
|
||||||
|
"node_role": item.get("node_role", ""),
|
||||||
|
"is_active": True,
|
||||||
|
"sort_order": index,
|
||||||
|
}
|
||||||
|
for index, item in enumerate(_normalize_chain_items(payload.chain_items, chain_nodes))
|
||||||
|
],
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
library = await load_theme_chain_library()
|
||||||
|
for item in library:
|
||||||
|
if item.get("theme") == normalized_name:
|
||||||
|
return item
|
||||||
|
return {"status": "ok", "theme": normalized_name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh")
|
||||||
|
async def refresh_research(_admin: dict = Depends(get_current_admin)):
|
||||||
|
from app.engine.recommender import get_latest_recommendations
|
||||||
|
from app.research.report_agent import build_research_report_async, save_research_report
|
||||||
|
|
||||||
|
result = await get_latest_recommendations()
|
||||||
|
latest_scan = result.get("latest_scan") or {}
|
||||||
|
scan_session = latest_scan.get("scan_session") or "manual_research"
|
||||||
|
report = await build_research_report_async(result, scan_session)
|
||||||
|
await save_research_report(report)
|
||||||
|
return {"status": "ok", "scan_session": scan_session, "opportunity_count": len(report.get("opportunity_cards", []))}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_json(value: str | None) -> dict:
|
||||||
|
if not value:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_chain_items(chain_items: list[dict], chain_nodes: list[str]) -> list[dict]:
|
||||||
|
if not chain_items:
|
||||||
|
return [
|
||||||
|
{"chain_node": node, "related_stocks": [], "leader_stocks": [], "node_role": ""}
|
||||||
|
for node in chain_nodes
|
||||||
|
]
|
||||||
|
normalized = []
|
||||||
|
for item in chain_items:
|
||||||
|
node = str(item.get("chain_node") or "").strip()
|
||||||
|
if not node:
|
||||||
|
continue
|
||||||
|
normalized.append({
|
||||||
|
"chain_node": node,
|
||||||
|
"related_stocks": _stock_list(item.get("related_stocks")),
|
||||||
|
"leader_stocks": _stock_list(item.get("leader_stocks")),
|
||||||
|
"node_role": str(item.get("node_role") or "").strip(),
|
||||||
|
})
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _stock_list(value) -> list:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
cleaned = []
|
||||||
|
for item in value:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
name = str(item.get("name") or "").strip()
|
||||||
|
ts_code = str(item.get("ts_code") or item.get("code") or "").strip()
|
||||||
|
if name or ts_code:
|
||||||
|
cleaned.append({"name": name, "ts_code": ts_code})
|
||||||
|
else:
|
||||||
|
text_value = str(item).strip()
|
||||||
|
if text_value:
|
||||||
|
cleaned.append(text_value)
|
||||||
|
return cleaned
|
||||||
@ -89,6 +89,24 @@ class Settings(BaseSettings):
|
|||||||
recommendation_push_max_items: int = 8
|
recommendation_push_max_items: int = 8
|
||||||
recommendation_push_dedup_ttl_seconds: int = 600
|
recommendation_push_dedup_ttl_seconds: int = 600
|
||||||
|
|
||||||
|
# 研究层风险 Agent
|
||||||
|
research_risk_enabled: bool = True
|
||||||
|
research_risk_stock_limit: int = 12
|
||||||
|
risk_unlock_lookahead_days: int = 45
|
||||||
|
risk_holder_trade_lookback_days: int = 120
|
||||||
|
risk_forecast_lookback_days: int = 210
|
||||||
|
risk_announcement_lookback_days: int = 180
|
||||||
|
risk_financial_lookback_days: int = 540
|
||||||
|
risk_unlock_reject_ratio: float = 15.0
|
||||||
|
risk_pledge_reject_ratio: float = 55.0
|
||||||
|
risk_goodwill_assets_warning_ratio: float = 20.0
|
||||||
|
risk_goodwill_assets_reject_ratio: float = 35.0
|
||||||
|
risk_debt_assets_warning_ratio: float = 70.0
|
||||||
|
risk_debt_assets_reject_ratio: float = 85.0
|
||||||
|
research_stock_llm_enabled: bool = True
|
||||||
|
research_stock_llm_limit: int = 8
|
||||||
|
research_stock_news_limit: int = 6
|
||||||
|
|
||||||
# 前端
|
# 前端
|
||||||
frontend_url: str = "http://localhost:3002"
|
frontend_url: str = "http://localhost:3002"
|
||||||
|
|
||||||
|
|||||||
@ -102,6 +102,10 @@ class MarketTemperature(BaseModel):
|
|||||||
broken_rate: float = 0 # 炸板率 %
|
broken_rate: float = 0 # 炸板率 %
|
||||||
index_above_ma20: bool = False # 上证在 MA20 上方
|
index_above_ma20: bool = False # 上证在 MA20 上方
|
||||||
temperature: float = 0 # 综合温度 0-100
|
temperature: float = 0 # 综合温度 0-100
|
||||||
|
source: str = "snapshot"
|
||||||
|
data_status: str = "fresh" # fresh / estimated / degraded / snapshot
|
||||||
|
source_detail: str = ""
|
||||||
|
limit_counts_reliable: bool = False
|
||||||
|
|
||||||
|
|
||||||
class MarketBreadth(BaseModel):
|
class MarketBreadth(BaseModel):
|
||||||
|
|||||||
@ -266,6 +266,127 @@ class TushareClient:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── 风险事件:解禁 / 减持 / 业绩预告 / 质押 ──
|
||||||
|
|
||||||
|
def get_share_float(
|
||||||
|
self,
|
||||||
|
ts_code: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""获取限售股解禁/流通股本变动信息。"""
|
||||||
|
key = f"share_float:{ts_code}:{start_date}:{end_date}"
|
||||||
|
return self._cached_fetch(
|
||||||
|
key, settings.cache_ttl_daily,
|
||||||
|
lambda: self.pro.share_float(
|
||||||
|
ts_code=ts_code,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
fields="ts_code,ann_date,float_date,float_share,float_ratio,holder_name,share_type"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_holder_trade(
|
||||||
|
self,
|
||||||
|
ts_code: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""获取股东增减持信息。"""
|
||||||
|
key = f"holder_trade:{ts_code}:{start_date}:{end_date}"
|
||||||
|
return self._cached_fetch(
|
||||||
|
key, settings.cache_ttl_daily,
|
||||||
|
lambda: self.pro.stk_holdertrade(
|
||||||
|
ts_code=ts_code,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
fields="ts_code,ann_date,holder_name,holder_type,in_de,change_vol,change_ratio,after_share,after_ratio,avg_price,total_share,begin_date,close_date"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_forecast(
|
||||||
|
self,
|
||||||
|
ts_code: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""获取业绩预告。"""
|
||||||
|
key = f"forecast:{ts_code}:{start_date}:{end_date}"
|
||||||
|
return self._cached_fetch(
|
||||||
|
key, settings.cache_ttl_daily,
|
||||||
|
lambda: self.pro.forecast(
|
||||||
|
ts_code=ts_code,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
fields="ts_code,ann_date,type,p_change_min,p_change_max,net_profit_min,net_profit_max,summary,change_reason"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_pledge_stat(self, ts_code: str) -> pd.DataFrame:
|
||||||
|
"""获取股权质押统计。"""
|
||||||
|
key = f"pledge_stat:{ts_code}"
|
||||||
|
return self._cached_fetch(
|
||||||
|
key, settings.cache_ttl_daily,
|
||||||
|
lambda: self.pro.pledge_stat(
|
||||||
|
ts_code=ts_code,
|
||||||
|
fields="ts_code,end_date,pledge_count,unrest_pledge,rest_pledge,total_share,pledge_ratio"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_announcements(
|
||||||
|
self,
|
||||||
|
ts_code: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""获取上市公司公告,用于扫描监管、诉讼、处罚等重大风险标题。"""
|
||||||
|
key = f"anns_d:{ts_code}:{start_date}:{end_date}"
|
||||||
|
return self._cached_fetch(
|
||||||
|
key, settings.cache_ttl_daily,
|
||||||
|
lambda: self.pro.anns_d(
|
||||||
|
ts_code=ts_code,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
fields="ts_code,ann_date,ann_time,title,url"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_fina_audit(
|
||||||
|
self,
|
||||||
|
ts_code: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""获取财务审计意见。"""
|
||||||
|
key = f"fina_audit:{ts_code}:{start_date}:{end_date}"
|
||||||
|
return self._cached_fetch(
|
||||||
|
key, settings.cache_ttl_daily,
|
||||||
|
lambda: self.pro.fina_audit(
|
||||||
|
ts_code=ts_code,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
fields="ts_code,ann_date,end_date,audit_result,audit_fees,audit_agency"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_balance_sheet(
|
||||||
|
self,
|
||||||
|
ts_code: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""获取资产负债表关键风险字段。"""
|
||||||
|
key = f"balancesheet:{ts_code}:{start_date}:{end_date}"
|
||||||
|
return self._cached_fetch(
|
||||||
|
key, settings.cache_ttl_daily,
|
||||||
|
lambda: self.pro.balancesheet(
|
||||||
|
ts_code=ts_code,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
fields="ts_code,ann_date,end_date,total_assets,total_liab,goodwill"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# ── 新闻快讯 ──
|
# ── 新闻快讯 ──
|
||||||
|
|
||||||
def get_news(
|
def get_news(
|
||||||
|
|||||||
@ -101,6 +101,33 @@ async def init_db():
|
|||||||
"ALTER TABLE news_items ADD COLUMN summary TEXT DEFAULT ''",
|
"ALTER TABLE news_items ADD COLUMN summary TEXT DEFAULT ''",
|
||||||
"ALTER TABLE news_items ADD COLUMN error TEXT DEFAULT ''",
|
"ALTER TABLE news_items ADD COLUMN error TEXT DEFAULT ''",
|
||||||
"ALTER TABLE catalysts ADD COLUMN llm_reason TEXT DEFAULT ''",
|
"ALTER TABLE catalysts ADD COLUMN llm_reason TEXT DEFAULT ''",
|
||||||
|
"ALTER TABLE stock_research_notes ADD COLUMN disagreement TEXT DEFAULT ''",
|
||||||
|
"ALTER TABLE stock_research_notes ADD COLUMN invalid_condition TEXT DEFAULT ''",
|
||||||
|
"ALTER TABLE stock_research_notes ADD COLUMN generated_by TEXT DEFAULT 'rules'",
|
||||||
|
"ALTER TABLE stock_research_notes ADD COLUMN stock_role TEXT DEFAULT '待归类'",
|
||||||
|
"ALTER TABLE stock_research_notes ADD COLUMN theme TEXT DEFAULT '未归类'",
|
||||||
|
"ALTER TABLE stock_research_notes ADD COLUMN chain_node TEXT DEFAULT '未归类'",
|
||||||
|
"ALTER TABLE opportunity_cards ADD COLUMN stock_role TEXT DEFAULT '待归类'",
|
||||||
|
"ALTER TABLE opportunity_cards ADD COLUMN alpha_type TEXT DEFAULT '观察线索'",
|
||||||
|
"ALTER TABLE opportunity_cards ADD COLUMN alpha_score REAL DEFAULT 0",
|
||||||
|
"ALTER TABLE opportunity_cards ADD COLUMN beta_dependency TEXT DEFAULT '中'",
|
||||||
|
"ALTER TABLE opportunity_cards ADD COLUMN beta_dependency_score REAL DEFAULT 0",
|
||||||
|
"ALTER TABLE opportunity_cards ADD COLUMN ambush_score REAL DEFAULT 0",
|
||||||
|
"ALTER TABLE opportunity_cards ADD COLUMN expectation_gap_score REAL DEFAULT 0",
|
||||||
|
"ALTER TABLE opportunity_cards ADD COLUMN risk_gate TEXT DEFAULT '通过'",
|
||||||
|
"ALTER TABLE opportunity_cards ADD COLUMN setup_quality TEXT DEFAULT '仅观察'",
|
||||||
|
"ALTER TABLE opportunity_cards ADD COLUMN alpha_reason TEXT DEFAULT ''",
|
||||||
|
"ALTER TABLE theme_knowledge ADD COLUMN lifecycle_status TEXT DEFAULT '观察期'",
|
||||||
|
"ALTER TABLE theme_knowledge ADD COLUMN stage TEXT DEFAULT 'mid'",
|
||||||
|
"ALTER TABLE theme_knowledge ADD COLUMN is_active BOOLEAN DEFAULT 1",
|
||||||
|
"ALTER TABLE theme_knowledge ADD COLUMN sort_order INTEGER DEFAULT 0",
|
||||||
|
"ALTER TABLE theme_knowledge ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP",
|
||||||
|
"ALTER TABLE theme_chain_knowledge ADD COLUMN related_stocks TEXT DEFAULT '[]'",
|
||||||
|
"ALTER TABLE theme_chain_knowledge ADD COLUMN leader_stocks TEXT DEFAULT '[]'",
|
||||||
|
"ALTER TABLE theme_chain_knowledge ADD COLUMN node_role TEXT DEFAULT ''",
|
||||||
|
"ALTER TABLE theme_chain_knowledge ADD COLUMN is_active BOOLEAN DEFAULT 1",
|
||||||
|
"ALTER TABLE theme_chain_knowledge ADD COLUMN sort_order INTEGER DEFAULT 0",
|
||||||
|
"ALTER TABLE theme_chain_knowledge ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP",
|
||||||
]:
|
]:
|
||||||
try:
|
try:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
@ -113,11 +140,21 @@ async def init_db():
|
|||||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_news_items_dedup_key ON news_items(dedup_key)",
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_news_items_dedup_key ON news_items(dedup_key)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_news_items_status_time ON news_items(status, published_at)",
|
"CREATE INDEX IF NOT EXISTS idx_news_items_status_time ON news_items(status, published_at)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_catalysts_source_url ON catalysts(source, url)",
|
"CREATE INDEX IF NOT EXISTS idx_catalysts_source_url ON catalysts(source, url)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_theme_knowledge_name ON theme_knowledge(theme_name)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_theme_knowledge_active_order ON theme_knowledge(is_active, sort_order)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_theme_chain_knowledge_theme ON theme_chain_knowledge(theme_name, is_active, sort_order)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_scan_process_session_time ON scan_process_logs(scan_session, created_at)",
|
"CREATE INDEX IF NOT EXISTS idx_scan_process_session_time ON scan_process_logs(scan_session, created_at)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_scan_process_stage_time ON scan_process_logs(stage, created_at)",
|
"CREATE INDEX IF NOT EXISTS idx_scan_process_stage_time ON scan_process_logs(stage, created_at)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_research_observations_session_score ON research_observations(scan_session, final_score)",
|
"CREATE INDEX IF NOT EXISTS idx_research_observations_session_score ON research_observations(scan_session, final_score)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_research_observations_code_time ON research_observations(ts_code, created_at)",
|
"CREATE INDEX IF NOT EXISTS idx_research_observations_code_time ON research_observations(ts_code, created_at)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_research_observations_theme_time ON research_observations(theme_name, created_at)",
|
"CREATE INDEX IF NOT EXISTS idx_research_observations_theme_time ON research_observations(theme_name, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_research_reports_session_time ON research_reports(scan_session, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_research_reports_trade_date ON research_reports(trade_date, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_theme_maps_trade_date_score ON theme_maps(trade_date, heat_score)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_theme_chain_theme ON theme_chain_nodes(theme_name, trade_date)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_stock_research_code_time ON stock_research_notes(ts_code, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_risk_events_session_reject ON risk_events(scan_session, reject)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_opportunity_cards_session_score ON opportunity_cards(scan_session, score)",
|
||||||
]:
|
]:
|
||||||
try:
|
try:
|
||||||
await conn.execute(__import__("sqlalchemy").text(index_sql))
|
await conn.execute(__import__("sqlalchemy").text(index_sql))
|
||||||
|
|||||||
@ -323,3 +323,129 @@ theme_catalysts_table = Table(
|
|||||||
Column("reason", Text, default=""),
|
Column("reason", Text, default=""),
|
||||||
Column("created_at", DateTime, server_default=func.now()),
|
Column("created_at", DateTime, server_default=func.now()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
theme_knowledge_table = Table(
|
||||||
|
"theme_knowledge", metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("theme_name", Text, nullable=False),
|
||||||
|
Column("aliases_json", Text, default="[]"),
|
||||||
|
Column("logic_summary", Text, default=""),
|
||||||
|
Column("lifecycle_status", Text, default="观察期"),
|
||||||
|
Column("stage", Text, default="mid"),
|
||||||
|
Column("is_active", Boolean, default=True),
|
||||||
|
Column("sort_order", Integer, default=0),
|
||||||
|
Column("created_at", DateTime, server_default=func.now()),
|
||||||
|
Column("updated_at", DateTime, server_default=func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
theme_chain_knowledge_table = Table(
|
||||||
|
"theme_chain_knowledge", metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("theme_name", Text, nullable=False),
|
||||||
|
Column("chain_node", Text, nullable=False),
|
||||||
|
Column("related_stocks", Text, default="[]"),
|
||||||
|
Column("leader_stocks", Text, default="[]"),
|
||||||
|
Column("node_role", Text, default=""),
|
||||||
|
Column("is_active", Boolean, default=True),
|
||||||
|
Column("sort_order", Integer, default=0),
|
||||||
|
Column("created_at", DateTime, server_default=func.now()),
|
||||||
|
Column("updated_at", DateTime, server_default=func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
research_reports_table = Table(
|
||||||
|
"research_reports", metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("scan_session", Text, nullable=False),
|
||||||
|
Column("trade_date", Text, nullable=False),
|
||||||
|
Column("market_summary", Text, default=""),
|
||||||
|
Column("theme_summary", Text, default=""),
|
||||||
|
Column("no_trade_reason", Text, default=""),
|
||||||
|
Column("report_json", Text, default="{}"),
|
||||||
|
Column("created_at", DateTime, server_default=func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
theme_maps_table = Table(
|
||||||
|
"theme_maps", metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("scan_session", Text, nullable=False),
|
||||||
|
Column("trade_date", Text, nullable=False),
|
||||||
|
Column("theme_name", Text, nullable=False),
|
||||||
|
Column("stage", Text, default="mid"),
|
||||||
|
Column("heat_score", Float, default=0),
|
||||||
|
Column("logic_summary", Text, default=""),
|
||||||
|
Column("lifecycle_status", Text, default=""),
|
||||||
|
Column("created_at", DateTime, server_default=func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
theme_chain_nodes_table = Table(
|
||||||
|
"theme_chain_nodes", metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("scan_session", Text, nullable=False),
|
||||||
|
Column("trade_date", Text, nullable=False),
|
||||||
|
Column("theme_name", Text, nullable=False),
|
||||||
|
Column("chain_node", Text, nullable=False),
|
||||||
|
Column("related_stocks", Text, default="[]"),
|
||||||
|
Column("leader_stocks", Text, default="[]"),
|
||||||
|
Column("created_at", DateTime, server_default=func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
stock_research_notes_table = Table(
|
||||||
|
"stock_research_notes", metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("scan_session", Text, nullable=False),
|
||||||
|
Column("trade_date", Text, nullable=False),
|
||||||
|
Column("ts_code", Text, nullable=False),
|
||||||
|
Column("name", Text, nullable=False),
|
||||||
|
Column("theme", Text, default="未归类"),
|
||||||
|
Column("chain_node", Text, default="未归类"),
|
||||||
|
Column("logic_score", Float, default=0),
|
||||||
|
Column("logic_summary", Text, default=""),
|
||||||
|
Column("evidence_json", Text, default="[]"),
|
||||||
|
Column("uncertainty", Text, default=""),
|
||||||
|
Column("stock_role", Text, default="待归类"),
|
||||||
|
Column("disagreement", Text, default=""),
|
||||||
|
Column("invalid_condition", Text, default=""),
|
||||||
|
Column("generated_by", Text, default="rules"),
|
||||||
|
Column("created_at", DateTime, server_default=func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
risk_events_table = Table(
|
||||||
|
"risk_events", metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("scan_session", Text, nullable=False),
|
||||||
|
Column("trade_date", Text, nullable=False),
|
||||||
|
Column("ts_code", Text, default=""),
|
||||||
|
Column("risk_type", Text, nullable=False),
|
||||||
|
Column("severity", Text, default="warning"),
|
||||||
|
Column("reject", Boolean, default=False),
|
||||||
|
Column("reason", Text, default=""),
|
||||||
|
Column("source", Text, default="research_agent"),
|
||||||
|
Column("created_at", DateTime, server_default=func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
opportunity_cards_table = Table(
|
||||||
|
"opportunity_cards", metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("scan_session", Text, nullable=False),
|
||||||
|
Column("trade_date", Text, nullable=False),
|
||||||
|
Column("ts_code", Text, nullable=False),
|
||||||
|
Column("name", Text, nullable=False),
|
||||||
|
Column("theme", Text, default=""),
|
||||||
|
Column("chain_node", Text, default="未归类"),
|
||||||
|
Column("stock_role", Text, default="待归类"),
|
||||||
|
Column("opportunity_type", Text, default="观察"),
|
||||||
|
Column("score", Float, default=0),
|
||||||
|
Column("alpha_type", Text, default="观察线索"),
|
||||||
|
Column("alpha_score", Float, default=0),
|
||||||
|
Column("beta_dependency", Text, default="中"),
|
||||||
|
Column("beta_dependency_score", Float, default=0),
|
||||||
|
Column("ambush_score", Float, default=0),
|
||||||
|
Column("expectation_gap_score", Float, default=0),
|
||||||
|
Column("risk_gate", Text, default="通过"),
|
||||||
|
Column("setup_quality", Text, default="仅观察"),
|
||||||
|
Column("alpha_reason", Text, default=""),
|
||||||
|
Column("action_plan", Text, default="观察"),
|
||||||
|
Column("trigger", Text, default=""),
|
||||||
|
Column("invalid_condition", Text, default=""),
|
||||||
|
Column("created_at", DateTime, server_default=func.now()),
|
||||||
|
)
|
||||||
|
|||||||
@ -163,6 +163,9 @@ async def refresh_recommendations(trade_date: str = None, scan_session: str = "m
|
|||||||
# 持久化到数据库(这是 async 操作,需要在主线程中执行)
|
# 持久化到数据库(这是 async 操作,需要在主线程中执行)
|
||||||
await _save_to_db(result)
|
await _save_to_db(result)
|
||||||
|
|
||||||
|
# 生成 AI 研究报告骨架:不改变推荐算法,只把扫描结果升级为研究产物。
|
||||||
|
await _save_research_layer(result, scan_session)
|
||||||
|
|
||||||
# 推送本轮可操作/重点关注推荐,失败不影响扫描结果。
|
# 推送本轮可操作/重点关注推荐,失败不影响扫描结果。
|
||||||
await _push_recommendation_notifications(result, scan_session)
|
await _push_recommendation_notifications(result, scan_session)
|
||||||
|
|
||||||
@ -190,6 +193,42 @@ async def _push_recommendation_notifications(result: dict, scan_session: str) ->
|
|||||||
logger.warning("飞书推荐推送失败: %s", e)
|
logger.warning("飞书推荐推送失败: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_research_layer(result: dict, scan_session: str) -> None:
|
||||||
|
try:
|
||||||
|
from app.research.report_agent import build_research_report_async, save_research_report
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
if not result.get("latest_scan"):
|
||||||
|
async with get_db() as db:
|
||||||
|
scan_row = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT * FROM scan_process_logs "
|
||||||
|
"WHERE scan_session = :session AND stage = 'final_filter' "
|
||||||
|
"ORDER BY created_at DESC, id DESC LIMIT 1"
|
||||||
|
),
|
||||||
|
{"session": scan_session},
|
||||||
|
)
|
||||||
|
result["latest_scan"] = _scan_meta_from_row(scan_row.fetchone())
|
||||||
|
report = await build_research_report_async(result, scan_session)
|
||||||
|
await save_research_report(report)
|
||||||
|
try:
|
||||||
|
from app.notifications.feishu import send_research_report_push
|
||||||
|
|
||||||
|
await send_research_report_push(report)
|
||||||
|
except Exception as push_error:
|
||||||
|
logger.warning("研究日报推送失败: %s", push_error)
|
||||||
|
logger.info(
|
||||||
|
"已生成研究报告: scan_session=%s opportunities=%s risks=%s",
|
||||||
|
scan_session,
|
||||||
|
len(report.get("opportunity_cards", [])),
|
||||||
|
len(report.get("risk_alerts", [])),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("研究报告生成失败: %s", e)
|
||||||
|
from app.db.error_logger import log_error
|
||||||
|
await log_error("research", f"研究报告生成失败: {e}", detail=traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
async def _update_tracking():
|
async def _update_tracking():
|
||||||
"""更新历史推荐的跟踪数据"""
|
"""更新历史推荐的跟踪数据"""
|
||||||
try:
|
try:
|
||||||
@ -851,6 +890,7 @@ async def _save_to_db(result: dict):
|
|||||||
# 保存市场温度
|
# 保存市场温度
|
||||||
mt = result.get("market_temp")
|
mt = result.get("market_temp")
|
||||||
if mt:
|
if mt:
|
||||||
|
_calibrate_market_temp_for_persistence(mt, result.get("hot_sectors", []) or [])
|
||||||
if _has_valid_market_breadth(mt):
|
if _has_valid_market_breadth(mt):
|
||||||
# 使用 INSERT OR REPLACE 确保重复扫描能更新数据
|
# 使用 INSERT OR REPLACE 确保重复扫描能更新数据
|
||||||
stmt = text(
|
stmt = text(
|
||||||
@ -988,6 +1028,22 @@ async def _save_to_db(result: dict):
|
|||||||
await log_error("recommender", f"保存推荐到数据库失败: {e}", detail=traceback.format_exc())
|
await log_error("recommender", f"保存推荐到数据库失败: {e}", detail=traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
|
def _calibrate_market_temp_for_persistence(market_temp, hot_sectors: list) -> None:
|
||||||
|
if not market_temp or not hot_sectors:
|
||||||
|
return
|
||||||
|
sector_limit_up = sum(max(int(getattr(sector, "limit_up_count", 0) or 0), 0) for sector in hot_sectors)
|
||||||
|
if sector_limit_up <= int(getattr(market_temp, "limit_up_count", 0) or 0):
|
||||||
|
return
|
||||||
|
original_limit = int(getattr(market_temp, "limit_up_count", 0) or 0)
|
||||||
|
original_temp = float(getattr(market_temp, "temperature", 0) or 0)
|
||||||
|
market_temp.limit_up_count = sector_limit_up
|
||||||
|
if original_limit == 0:
|
||||||
|
market_temp.temperature = round(min(original_temp + min(sector_limit_up / 5, 8), 100), 1)
|
||||||
|
if hasattr(market_temp, "data_status") and not getattr(market_temp, "limit_counts_reliable", False):
|
||||||
|
market_temp.data_status = "estimated"
|
||||||
|
market_temp.source_detail = f"{getattr(market_temp, 'source_detail', '')};sector_limit_lower_bound"
|
||||||
|
|
||||||
|
|
||||||
async def _load_today_from_db() -> dict:
|
async def _load_today_from_db() -> dict:
|
||||||
"""从数据库加载今日推荐"""
|
"""从数据库加载今日推荐"""
|
||||||
today = datetime.now().strftime("%Y-%m-%d")
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
@ -1113,10 +1169,11 @@ async def _load_today_from_db() -> dict:
|
|||||||
strategy_profile = await _load_latest_strategy_profile_for_session(db, latest_scan_session)
|
strategy_profile = await _load_latest_strategy_profile_for_session(db, latest_scan_session)
|
||||||
if not strategy_profile and recommendations:
|
if not strategy_profile and recommendations:
|
||||||
strategy_profile = get_strategy_profile_by_id(recommendations[0].strategy).model_dump()
|
strategy_profile = get_strategy_profile_by_id(recommendations[0].strategy).model_dump()
|
||||||
|
latest_sectors = await _load_sectors_from_db()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"market_temp": market_temp,
|
"market_temp": market_temp,
|
||||||
"hot_sectors": [],
|
"hot_sectors": latest_sectors,
|
||||||
"capital_filtered": [],
|
"capital_filtered": [],
|
||||||
"recommendations": recommendations,
|
"recommendations": recommendations,
|
||||||
"strategy_profile": strategy_profile,
|
"strategy_profile": strategy_profile,
|
||||||
|
|||||||
@ -155,6 +155,20 @@ async def run_screening(trade_date: str = None, scan_session: str = "manual") ->
|
|||||||
merge_sectors_to_themes(await intraday_sector_scan(hot_sectors), limit=settings.top_sector_count)
|
merge_sectors_to_themes(await intraday_sector_scan(hot_sectors), limit=settings.top_sector_count)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
calibration = _calibrate_market_temperature_from_sectors(market_temp, hot_sectors)
|
||||||
|
if calibration:
|
||||||
|
await log_scan_stage(
|
||||||
|
scan_session=scan_session,
|
||||||
|
scan_mode=scan_mode,
|
||||||
|
stage="market_breadth_calibration",
|
||||||
|
stage_label="市场情绪校准",
|
||||||
|
input_count=len(hot_sectors),
|
||||||
|
output_count=1,
|
||||||
|
filtered_count=0,
|
||||||
|
summary=calibration["summary"],
|
||||||
|
detail=calibration,
|
||||||
|
)
|
||||||
|
|
||||||
strategy_profile = await select_strategy_profile(market_temp, hot_sectors, intraday, scan_session=scan_session)
|
strategy_profile = await select_strategy_profile(market_temp, hot_sectors, intraday, scan_session=scan_session)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"=== 今日策略: {strategy_profile.name} ({strategy_profile.strategy_id}) "
|
f"=== 今日策略: {strategy_profile.name} ({strategy_profile.strategy_id}) "
|
||||||
@ -792,6 +806,41 @@ def _route_recall_weight(route: str, item: dict) -> float:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _calibrate_market_temperature_from_sectors(
|
||||||
|
market_temp: MarketTemperature,
|
||||||
|
hot_sectors: list[SectorInfo],
|
||||||
|
) -> dict | None:
|
||||||
|
"""Use theme limit-up counts as a lower bound when market breadth limits are missing."""
|
||||||
|
if not market_temp or not hot_sectors:
|
||||||
|
return None
|
||||||
|
sector_limit_up = sum(max(int(getattr(sector, "limit_up_count", 0) or 0), 0) for sector in hot_sectors)
|
||||||
|
sector_limit_down = sum(max(int(getattr(sector, "realtime_down_count", 0) or 0), 0) for sector in hot_sectors)
|
||||||
|
if sector_limit_up <= 0:
|
||||||
|
return None
|
||||||
|
original_limit_up = int(market_temp.limit_up_count or 0)
|
||||||
|
original_temp = float(market_temp.temperature or 0)
|
||||||
|
if original_limit_up >= sector_limit_up:
|
||||||
|
return None
|
||||||
|
|
||||||
|
market_temp.limit_up_count = sector_limit_up
|
||||||
|
if sector_limit_down and not market_temp.limit_down_count:
|
||||||
|
market_temp.limit_down_count = sector_limit_down
|
||||||
|
if not getattr(market_temp, "limit_counts_reliable", False):
|
||||||
|
market_temp.data_status = "estimated"
|
||||||
|
market_temp.source_detail = f"{getattr(market_temp, 'source_detail', '')};sector_limit_lower_bound"
|
||||||
|
if original_limit_up == 0:
|
||||||
|
market_temp.temperature = round(min(original_temp + min(sector_limit_up / 5, 8), 100), 1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": f"全市场涨停数由 {original_limit_up} 校准为至少 {sector_limit_up},市场温度 {original_temp:.1f} -> {market_temp.temperature:.1f}",
|
||||||
|
"original_limit_up_count": original_limit_up,
|
||||||
|
"calibrated_limit_up_count": sector_limit_up,
|
||||||
|
"original_temperature": original_temp,
|
||||||
|
"calibrated_temperature": market_temp.temperature,
|
||||||
|
"source": "sector_limit_lower_bound",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _finalize_battle_plan(
|
def _finalize_battle_plan(
|
||||||
recommendations: list[Recommendation],
|
recommendations: list[Recommendation],
|
||||||
hot_sectors: list[SectorInfo],
|
hot_sectors: list[SectorInfo],
|
||||||
|
|||||||
@ -11,7 +11,7 @@ from app.config import settings
|
|||||||
from app.db.error_logger import PersistentErrorLogHandler, log_error
|
from app.db.error_logger import PersistentErrorLogHandler, log_error
|
||||||
from app.db.database import init_db
|
from app.db.database import init_db
|
||||||
from app.engine.scheduler import start_scheduler, stop_scheduler
|
from app.engine.scheduler import start_scheduler, stop_scheduler
|
||||||
from app.api import market, sectors, recommendations, stocks, watchlists, websocket, chat, auth, catalysts
|
from app.api import market, sectors, recommendations, stocks, watchlists, websocket, chat, auth, catalysts, research, debug
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -145,6 +145,8 @@ app.include_router(watchlists.router)
|
|||||||
app.include_router(chat.router)
|
app.include_router(chat.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(catalysts.router)
|
app.include_router(catalysts.router)
|
||||||
|
app.include_router(research.router)
|
||||||
|
app.include_router(debug.router)
|
||||||
|
|
||||||
# WebSocket
|
# WebSocket
|
||||||
app.websocket("/ws")(websocket.ws_endpoint)
|
app.websocket("/ws")(websocket.ws_endpoint)
|
||||||
|
|||||||
@ -298,3 +298,92 @@ async def send_recommendation_push(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Feishu 推荐推送失败: %s", e)
|
logger.warning("Feishu 推荐推送失败: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _research_signature(report: dict) -> str:
|
||||||
|
basis = "|".join([
|
||||||
|
str(report.get("scan_session", "")),
|
||||||
|
str(report.get("trade_date", "")),
|
||||||
|
str(len(report.get("opportunity_cards", []) or [])),
|
||||||
|
str((report.get("no_trade_reason") or {}).get("reason", "")),
|
||||||
|
])
|
||||||
|
return hashlib.sha1(basis.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_research_card(report: dict, now: str) -> dict:
|
||||||
|
market = report.get("market_view") or {}
|
||||||
|
themes = report.get("theme_views") or []
|
||||||
|
opportunities = report.get("opportunity_cards") or []
|
||||||
|
risks = report.get("risk_alerts") or []
|
||||||
|
no_trade = report.get("no_trade_reason") or {}
|
||||||
|
header_template = "red" if opportunities else "orange"
|
||||||
|
|
||||||
|
summary = "\n".join([
|
||||||
|
f"**扫描**: {report.get('scan_session') or '-'}",
|
||||||
|
f"**时间**: {now}",
|
||||||
|
f"**市场状态**: {market.get('summary') or '-'}",
|
||||||
|
f"**机会卡**: {len(opportunities)} 个 **风险**: {len(risks)} 个",
|
||||||
|
])
|
||||||
|
elements: list[dict] = [{"tag": "div", "text": _card_text(summary)}, {"tag": "hr"}]
|
||||||
|
|
||||||
|
if themes:
|
||||||
|
theme_lines = []
|
||||||
|
for item in themes[:3]:
|
||||||
|
nodes = " / ".join((item.get("chain_nodes") or [])[:4])
|
||||||
|
theme_lines.append(f"**{item.get('theme')}** {item.get('heat_score', 0):.0f}分 · {item.get('lifecycle_status') or item.get('stage')} · {nodes}")
|
||||||
|
elements.append({"tag": "div", "text": _card_text("**主线/产业链**\n" + "\n".join(theme_lines))})
|
||||||
|
|
||||||
|
if opportunities:
|
||||||
|
elements.append({"tag": "hr"})
|
||||||
|
lines = []
|
||||||
|
for item in opportunities[:5]:
|
||||||
|
lines.append(f"**{item.get('name')}** `{item.get('ts_code')}` · {item.get('opportunity_type')} · {item.get('theme')}/{item.get('chain_node')} · {item.get('score')}分")
|
||||||
|
elements.append({"tag": "div", "text": _card_text("**Top 机会**\n" + "\n".join(lines))})
|
||||||
|
else:
|
||||||
|
elements.append({"tag": "hr"})
|
||||||
|
elements.append({"tag": "div", "text": _card_text(f"**本轮无交易级机会**\n{no_trade.get('reason') or '没有形成满足条件的机会卡。'}")})
|
||||||
|
|
||||||
|
if risks:
|
||||||
|
elements.append({"tag": "hr"})
|
||||||
|
risk_lines = [f"{'否决' if item.get('reject') else '预警'} · {item.get('reason')}" for item in risks[:3]]
|
||||||
|
elements.append({"tag": "div", "text": _card_text("**风险雷达**\n" + "\n".join(risk_lines))})
|
||||||
|
|
||||||
|
elements.append({
|
||||||
|
"tag": "note",
|
||||||
|
"elements": [_card_text("研究日报用于解释市场、主线和风险;交易仍需等待触发条件确认。", tag="plain_text")],
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"config": {"wide_screen_mode": True},
|
||||||
|
"header": {
|
||||||
|
"template": header_template,
|
||||||
|
"title": _card_text(f"{settings.alert_app_name} 研究日报", tag="plain_text"),
|
||||||
|
},
|
||||||
|
"elements": elements,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def send_research_report_push(report: dict) -> bool:
|
||||||
|
webhook_url = settings.recommendation_push_webhook_url or settings.feishu_webhook_url
|
||||||
|
if not settings.recommendation_push_enabled or not webhook_url:
|
||||||
|
return False
|
||||||
|
|
||||||
|
signature = _research_signature(report)
|
||||||
|
dedup_key = f"feishu_research_report:{signature}"
|
||||||
|
if cache.get(dedup_key):
|
||||||
|
return False
|
||||||
|
cache.set(dedup_key, True, settings.recommendation_push_dedup_ttl_seconds)
|
||||||
|
|
||||||
|
now = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
payload = {"msg_type": "interactive", "card": _build_research_card(report, now)}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=8, follow_redirects=True) as client:
|
||||||
|
resp = await client.post(webhook_url, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
body = resp.json()
|
||||||
|
if body.get("code", 0) != 0:
|
||||||
|
logger.warning("Feishu 研究日报推送失败: %s", body)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Feishu 研究日报推送失败: %s", e)
|
||||||
|
return False
|
||||||
|
|||||||
2
backend/app/research/__init__.py
Normal file
2
backend/app/research/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
"""AI research layer for A-share opportunity discovery."""
|
||||||
|
|
||||||
16
backend/app/research/catalyst_agent.py
Normal file
16
backend/app/research/catalyst_agent.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
"""Catalyst summary agent."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def build_catalyst_summary(theme_views: list[dict]) -> dict:
|
||||||
|
strong = [item for item in theme_views if item.get("heat_score", 0) >= 70]
|
||||||
|
themes = [item["theme"] for item in strong[:3]]
|
||||||
|
if themes:
|
||||||
|
summary = f"强催化/强热度主题集中在 {'、'.join(themes)}。"
|
||||||
|
elif theme_views:
|
||||||
|
summary = "主题热度存在但强催化不足,需等待新闻、政策或资金进一步确认。"
|
||||||
|
else:
|
||||||
|
summary = "暂未形成有效主题催化。"
|
||||||
|
return {"summary": summary, "strong_theme_count": len(strong), "themes": themes}
|
||||||
|
|
||||||
80
backend/app/research/feedback_agent.py
Normal file
80
backend/app/research/feedback_agent.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
"""Review feedback weights for opportunity ranking."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.research.review_agent import build_research_review
|
||||||
|
|
||||||
|
|
||||||
|
async def build_ranking_feedback(days: int = 60) -> dict:
|
||||||
|
review = await build_research_review(days=days)
|
||||||
|
theme_weights = _weights_from_breakdown(review.get("theme_breakdown", []), positive=True)
|
||||||
|
chain_weights = _weights_from_breakdown(review.get("chain_breakdown", []), positive=True)
|
||||||
|
signal_weights = _weights_from_breakdown(review.get("signal_breakdown", []), positive=True)
|
||||||
|
risk_weights = _weights_from_breakdown(review.get("risk_breakdown", []), positive=False)
|
||||||
|
return {
|
||||||
|
"days": days,
|
||||||
|
"sample_count": review.get("sample_count", 0),
|
||||||
|
"tracked_count": review.get("tracked_count", 0),
|
||||||
|
"theme_weights": theme_weights,
|
||||||
|
"chain_weights": chain_weights,
|
||||||
|
"signal_weights": signal_weights,
|
||||||
|
"risk_weights": risk_weights,
|
||||||
|
"summary": review.get("summary", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_feedback_to_card(card: dict, rec, risk_alerts: list[dict], feedback: dict | None) -> dict:
|
||||||
|
if not feedback or int(feedback.get("tracked_count") or 0) < 5:
|
||||||
|
return {**card, "review_adjustment": 0, "adjusted_score": card.get("score", 0), "review_feedback": []}
|
||||||
|
|
||||||
|
adjustments: list[dict] = []
|
||||||
|
theme = str(card.get("theme") or "")
|
||||||
|
chain_node = str(card.get("chain_node") or "")
|
||||||
|
signal = str(getattr(rec, "entry_signal_type", "") or "")
|
||||||
|
|
||||||
|
_append_adjustment(adjustments, "主题", theme, feedback.get("theme_weights", {}).get(theme, 0))
|
||||||
|
_append_adjustment(adjustments, "环节", chain_node, feedback.get("chain_weights", {}).get(chain_node, 0))
|
||||||
|
_append_adjustment(adjustments, "信号", signal, feedback.get("signal_weights", {}).get(signal, 0))
|
||||||
|
|
||||||
|
ts_code = str(card.get("ts_code") or "")
|
||||||
|
for risk in risk_alerts:
|
||||||
|
if risk.get("ts_code") and risk.get("ts_code") != ts_code:
|
||||||
|
continue
|
||||||
|
risk_type = str(risk.get("risk_type") or "")
|
||||||
|
_append_adjustment(adjustments, "风险", risk_type, feedback.get("risk_weights", {}).get(risk_type, 0))
|
||||||
|
|
||||||
|
total = round(sum(item["delta"] for item in adjustments), 1)
|
||||||
|
base_score = float(card.get("score") or 0)
|
||||||
|
adjusted = round(max(0, min(100, base_score + total)), 1)
|
||||||
|
return {
|
||||||
|
**card,
|
||||||
|
"review_adjustment": total,
|
||||||
|
"adjusted_score": adjusted,
|
||||||
|
"review_feedback": adjustments[:5],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _weights_from_breakdown(items: list[dict], positive: bool) -> dict[str, float]:
|
||||||
|
weights: dict[str, float] = {}
|
||||||
|
for item in items:
|
||||||
|
label = str(item.get("label") or "")
|
||||||
|
tracked = int(item.get("tracked_count") or 0)
|
||||||
|
if not label or tracked < 2:
|
||||||
|
continue
|
||||||
|
avg_return = float(item.get("avg_return") or 0)
|
||||||
|
win_rate = float(item.get("win_rate") or 0)
|
||||||
|
if positive:
|
||||||
|
raw = (win_rate - 50) / 12 + avg_return * 0.65
|
||||||
|
delta = max(-4.0, min(6.0, raw))
|
||||||
|
else:
|
||||||
|
raw = avg_return * 0.85 - max(0, 45 - win_rate) / 10
|
||||||
|
delta = max(-8.0, min(0.0, raw))
|
||||||
|
if abs(delta) >= 0.5:
|
||||||
|
weights[label] = round(delta, 1)
|
||||||
|
return weights
|
||||||
|
|
||||||
|
|
||||||
|
def _append_adjustment(adjustments: list[dict], source: str, label: str, delta: float) -> None:
|
||||||
|
if not label or not delta:
|
||||||
|
return
|
||||||
|
adjustments.append({"source": source, "label": label, "delta": round(float(delta), 1)})
|
||||||
245
backend/app/research/industry_chain_agent.py
Normal file
245
backend/app/research/industry_chain_agent.py
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
"""Theme and industry-chain mapping.
|
||||||
|
|
||||||
|
The first version is intentionally deterministic and editable. It gives the
|
||||||
|
research layer a stable vocabulary before LLM-assisted updates are introduced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.db import tables
|
||||||
|
from app.db.database import get_db
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThemeChain:
|
||||||
|
theme: str
|
||||||
|
aliases: tuple[str, ...]
|
||||||
|
nodes: tuple[str, ...]
|
||||||
|
logic: str
|
||||||
|
stage: str = "mid"
|
||||||
|
lifecycle_status: str = "观察期"
|
||||||
|
|
||||||
|
|
||||||
|
THEME_CHAINS: tuple[ThemeChain, ...] = (
|
||||||
|
ThemeChain("AI算力", ("AI", "算力", "人工智能", "光模块", "服务器", "液冷", "PCB", "CPO"), ("光模块", "PCB", "服务器", "液冷", "IDC", "电源", "铜缆高速连接"), "AI 基础设施扩张带动上游硬件和数据中心链条。"),
|
||||||
|
ThemeChain("机器人", ("机器人", "人形机器人", "机器人装备", "减速器", "伺服", "控制器"), ("减速器", "伺服系统", "控制器", "传感器", "本体制造", "机器视觉"), "人形机器人产业趋势扩散,重点观察核心零部件和前排整机。"),
|
||||||
|
ThemeChain("低空经济", ("低空", "飞行汽车", "eVTOL", "通航", "无人机"), ("整机", "飞控", "电池", "空管", "材料", "运营服务"), "政策驱动低空基础设施和飞行器产业链加速。"),
|
||||||
|
ThemeChain("创新药", ("创新药", "医药", "CXO", "生物医药", "减肥药"), ("创新药企", "CXO", "原料药", "医疗器械", "商业化平台"), "政策、临床进展和出海订单共同驱动医药成长线。"),
|
||||||
|
ThemeChain("军工", ("军工", "航天", "航空", "卫星", "船舶"), ("航空装备", "航天电子", "卫星互联网", "船舶", "军工材料"), "订单周期、装备升级和地缘扰动共同驱动军工链。"),
|
||||||
|
ThemeChain("固态电池", ("固态电池", "锂电", "电池", "新能源车"), ("电解质", "正极材料", "负极材料", "隔膜", "设备", "整车验证"), "电池技术迭代推动材料和设备环节重估。"),
|
||||||
|
ThemeChain("半导体", ("半导体", "芯片", "集成电路", "存储", "先进封装"), ("设备", "材料", "设计", "制造", "封测", "存储"), "国产替代和周期复苏共同影响半导体链。"),
|
||||||
|
ThemeChain("传媒游戏", ("传媒", "游戏", "短剧", "影视", "AIGC"), ("游戏", "影视院线", "短剧", "营销", "版权IP", "AIGC应用"), "内容供给、AI 应用和监管周期变化驱动传媒弹性。"),
|
||||||
|
ThemeChain("化工材料", ("化工", "材料", "有机硅", "氟化工", "化纤"), ("基础化工", "新材料", "氟化工", "有机硅", "化纤", "电子化学品"), "价格周期和新材料需求共同影响化工材料链。"),
|
||||||
|
ThemeChain("有色金属", ("有色", "铜", "铝", "黄金", "稀土", "锂"), ("铜铝", "贵金属", "稀土", "锂资源", "加工材"), "通胀预期、供需缺口和新能源需求影响资源品。"),
|
||||||
|
ThemeChain("新能源", ("光伏", "风电", "储能", "新能源", "逆变器"), ("光伏组件", "逆变器", "储能", "风电设备", "电网设备"), "装机需求和价格出清影响新能源链条。"),
|
||||||
|
ThemeChain("消费电子", ("消费电子", "苹果", "MR", "折叠屏", "智能穿戴"), ("结构件", "面板", "光学", "声学", "组装", "芯片"), "终端创新周期带动零部件弹性。"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_theme(name: str) -> ThemeChain | None:
|
||||||
|
normalized = name or ""
|
||||||
|
for theme in THEME_CHAINS:
|
||||||
|
if theme.theme in normalized or any(alias in normalized for alias in theme.aliases):
|
||||||
|
return theme
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_theme_knowledge_seeded() -> None:
|
||||||
|
"""Seed editable theme knowledge from the built-in v1 map when empty."""
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(text("SELECT COUNT(*) AS count FROM theme_knowledge"))
|
||||||
|
count = int(result.fetchone()._mapping["count"])
|
||||||
|
if count:
|
||||||
|
return
|
||||||
|
now_values = []
|
||||||
|
for index, theme in enumerate(THEME_CHAINS):
|
||||||
|
now_values.append({
|
||||||
|
"theme_name": theme.theme,
|
||||||
|
"aliases_json": json.dumps(list(theme.aliases), ensure_ascii=False),
|
||||||
|
"logic_summary": theme.logic,
|
||||||
|
"lifecycle_status": theme.lifecycle_status,
|
||||||
|
"stage": theme.stage,
|
||||||
|
"is_active": True,
|
||||||
|
"sort_order": index,
|
||||||
|
})
|
||||||
|
await db.execute(tables.theme_knowledge_table.insert(), now_values)
|
||||||
|
|
||||||
|
node_values = []
|
||||||
|
for theme_index, theme in enumerate(THEME_CHAINS):
|
||||||
|
for node_index, node in enumerate(theme.nodes):
|
||||||
|
node_values.append({
|
||||||
|
"theme_name": theme.theme,
|
||||||
|
"chain_node": node,
|
||||||
|
"related_stocks": "[]",
|
||||||
|
"leader_stocks": "[]",
|
||||||
|
"node_role": "",
|
||||||
|
"is_active": True,
|
||||||
|
"sort_order": theme_index * 100 + node_index,
|
||||||
|
})
|
||||||
|
if node_values:
|
||||||
|
await db.execute(tables.theme_chain_knowledge_table.insert(), node_values)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def load_theme_chain_library() -> list[dict[str, Any]]:
|
||||||
|
await ensure_theme_knowledge_seeded()
|
||||||
|
async with get_db() as db:
|
||||||
|
themes_result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT * FROM theme_knowledge "
|
||||||
|
"WHERE is_active = 1 ORDER BY sort_order ASC, id ASC"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
nodes_result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT * FROM theme_chain_knowledge "
|
||||||
|
"WHERE is_active = 1 ORDER BY sort_order ASC, id ASC"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
node_map: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for row in nodes_result.fetchall():
|
||||||
|
item = dict(row._mapping)
|
||||||
|
node_map.setdefault(str(item.get("theme_name") or ""), []).append(item)
|
||||||
|
|
||||||
|
library = []
|
||||||
|
for row in themes_result.fetchall():
|
||||||
|
item = dict(row._mapping)
|
||||||
|
theme_name = str(item.get("theme_name") or "")
|
||||||
|
nodes = node_map.get(theme_name, [])
|
||||||
|
library.append({
|
||||||
|
"theme": theme_name,
|
||||||
|
"aliases": _safe_json_list(item.get("aliases_json")),
|
||||||
|
"logic": item.get("logic_summary") or "",
|
||||||
|
"stage": item.get("stage") or "mid",
|
||||||
|
"lifecycle_status": item.get("lifecycle_status") or "观察期",
|
||||||
|
"chain_nodes": [node.get("chain_node") for node in nodes if node.get("chain_node")] or ["未归类"],
|
||||||
|
"chain_items": [
|
||||||
|
{
|
||||||
|
"chain_node": node.get("chain_node") or "",
|
||||||
|
"related_stocks": _safe_json_list(node.get("related_stocks")),
|
||||||
|
"leader_stocks": _safe_json_list(node.get("leader_stocks")),
|
||||||
|
"node_role": node.get("node_role") or "",
|
||||||
|
}
|
||||||
|
for node in nodes
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return library
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_theme_from_library(name: str, library: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||||
|
normalized = name or ""
|
||||||
|
for theme in library:
|
||||||
|
theme_name = str(theme.get("theme") or "")
|
||||||
|
aliases = [str(alias) for alias in theme.get("aliases", [])]
|
||||||
|
if theme_name in normalized or any(alias and alias in normalized for alias in aliases):
|
||||||
|
return theme
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def map_sector_to_chain(sector_name: str, leading_stocks: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||||
|
theme = resolve_theme(sector_name)
|
||||||
|
if not theme:
|
||||||
|
return {
|
||||||
|
"theme": sector_name or "未归类",
|
||||||
|
"logic": "暂未命中内置产业链图谱,保留为未归类主题等待后续研究补全。",
|
||||||
|
"chain_nodes": ["未归类"],
|
||||||
|
"leader_stocks": leading_stocks or [],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"theme": theme.theme,
|
||||||
|
"logic": theme.logic,
|
||||||
|
"chain_nodes": list(theme.nodes),
|
||||||
|
"chain_items": [
|
||||||
|
{"chain_node": node, "related_stocks": [], "leader_stocks": [], "node_role": ""}
|
||||||
|
for node in theme.nodes
|
||||||
|
],
|
||||||
|
"leader_stocks": leading_stocks or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def map_sector_to_chain_from_library(
|
||||||
|
sector_name: str,
|
||||||
|
leading_stocks: list[dict[str, Any]] | None,
|
||||||
|
library: list[dict[str, Any]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
theme = resolve_theme_from_library(sector_name, library)
|
||||||
|
if not theme:
|
||||||
|
return map_sector_to_chain(sector_name, leading_stocks)
|
||||||
|
return {
|
||||||
|
"theme": theme["theme"],
|
||||||
|
"logic": theme["logic"] or "主题逻辑待补充。",
|
||||||
|
"chain_nodes": theme["chain_nodes"],
|
||||||
|
"chain_items": theme.get("chain_items", []),
|
||||||
|
"leader_stocks": leading_stocks or [],
|
||||||
|
"stage": theme.get("stage") or "mid",
|
||||||
|
"lifecycle_status": theme.get("lifecycle_status") or "观察期",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def infer_chain_node(theme_name: str, stock_name: str = "", sector_name: str = "") -> str:
|
||||||
|
theme = resolve_theme(theme_name) or resolve_theme(sector_name)
|
||||||
|
if not theme:
|
||||||
|
return "未归类"
|
||||||
|
text = f"{stock_name}{sector_name}{theme_name}"
|
||||||
|
for node in theme.nodes:
|
||||||
|
if node in text:
|
||||||
|
return node
|
||||||
|
return theme.nodes[0] if theme.nodes else "未归类"
|
||||||
|
|
||||||
|
|
||||||
|
def infer_chain_position_from_theme_view(theme_view: dict[str, Any], ts_code: str = "", stock_name: str = "") -> dict[str, str]:
|
||||||
|
"""Infer a candidate's industry-chain node and role from editable theme knowledge."""
|
||||||
|
chain_items = theme_view.get("chain_items") or []
|
||||||
|
for item in chain_items:
|
||||||
|
node = str(item.get("chain_node") or "")
|
||||||
|
node_role = str(item.get("node_role") or "")
|
||||||
|
if _stock_in_list(ts_code, stock_name, item.get("leader_stocks") or []):
|
||||||
|
return {"chain_node": node or "未归类", "stock_role": node_role or "核心股"}
|
||||||
|
if _stock_in_list(ts_code, stock_name, item.get("related_stocks") or []):
|
||||||
|
return {"chain_node": node or "未归类", "stock_role": node_role or "相关股"}
|
||||||
|
|
||||||
|
text_value = f"{stock_name}{theme_view.get('raw_sector', '')}{theme_view.get('theme', '')}"
|
||||||
|
for item in chain_items:
|
||||||
|
node = str(item.get("chain_node") or "")
|
||||||
|
if node and node in text_value:
|
||||||
|
return {"chain_node": node, "stock_role": str(item.get("node_role") or "环节标的")}
|
||||||
|
|
||||||
|
nodes = theme_view.get("chain_nodes") or []
|
||||||
|
fallback_node = str(nodes[0]) if nodes else infer_chain_node(str(theme_view.get("theme") or ""), stock_name, str(theme_view.get("raw_sector") or ""))
|
||||||
|
return {"chain_node": fallback_node or "未归类", "stock_role": "待归类"}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_json_list(value: Any) -> list[Any]:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
parsed = json.loads(str(value))
|
||||||
|
return parsed if isinstance(parsed, list) else []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _stock_in_list(ts_code: str, stock_name: str, values: list[Any]) -> bool:
|
||||||
|
code = (ts_code or "").upper()
|
||||||
|
name = stock_name or ""
|
||||||
|
for value in values:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
raw_code = str(value.get("ts_code") or value.get("code") or "").upper()
|
||||||
|
raw_name = str(value.get("name") or "")
|
||||||
|
if raw_code and raw_code == code:
|
||||||
|
return True
|
||||||
|
if raw_name and raw_name == name:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
text_value = str(value)
|
||||||
|
if text_value and (text_value.upper() == code or text_value == name):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
51
backend/app/research/market_agent.py
Normal file
51
backend/app/research/market_agent.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"""Market regime research agent."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def build_market_view(market_temp: Any, strategy_profile: dict | None = None) -> dict:
|
||||||
|
temp = float(getattr(market_temp, "temperature", 0) or 0)
|
||||||
|
up = int(getattr(market_temp, "up_count", 0) or 0)
|
||||||
|
down = int(getattr(market_temp, "down_count", 0) or 0)
|
||||||
|
limit_up = int(getattr(market_temp, "limit_up_count", 0) or 0)
|
||||||
|
limit_down = int(getattr(market_temp, "limit_down_count", 0) or 0)
|
||||||
|
breadth_total = max(up + down, 1)
|
||||||
|
up_ratio = up / breadth_total
|
||||||
|
|
||||||
|
if temp >= 70 and limit_up >= 60:
|
||||||
|
regime = "bullish_mainline"
|
||||||
|
summary = "市场情绪偏强,主线进攻窗口打开。"
|
||||||
|
elif temp >= 55 and up_ratio >= 0.52:
|
||||||
|
regime = "bullish_rotation"
|
||||||
|
summary = "市场偏强但仍需确认主线持续性,适合围绕前排轮动。"
|
||||||
|
elif temp >= 40:
|
||||||
|
regime = "range_rotation"
|
||||||
|
summary = "市场震荡轮动,优先等待回踩和承接确认。"
|
||||||
|
elif limit_down > max(limit_up, 1):
|
||||||
|
regime = "risk_off"
|
||||||
|
summary = "市场风险偏好较弱,优先防守和降低出手频率。"
|
||||||
|
else:
|
||||||
|
regime = "defensive_watch"
|
||||||
|
summary = "市场温度偏低,保留观察,不主动扩大仓位。"
|
||||||
|
|
||||||
|
confidence = min(0.95, max(0.45, abs(temp - 50) / 60 + abs(up_ratio - 0.5) + 0.45))
|
||||||
|
stance = (strategy_profile or {}).get("market_stance") or ""
|
||||||
|
if stance:
|
||||||
|
summary = f"{summary}{stance}策略生效。"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"regime": regime,
|
||||||
|
"confidence": round(confidence, 2),
|
||||||
|
"summary": summary,
|
||||||
|
"temperature": round(temp, 1),
|
||||||
|
"up_count": up,
|
||||||
|
"down_count": down,
|
||||||
|
"limit_up_count": limit_up,
|
||||||
|
"limit_down_count": limit_down,
|
||||||
|
"source": getattr(market_temp, "source", "snapshot"),
|
||||||
|
"data_status": getattr(market_temp, "data_status", "fresh"),
|
||||||
|
"source_detail": getattr(market_temp, "source_detail", ""),
|
||||||
|
"limit_counts_reliable": bool(getattr(market_temp, "limit_counts_reliable", False)),
|
||||||
|
}
|
||||||
193
backend/app/research/ranking_agent.py
Normal file
193
backend/app/research/ranking_agent.py
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
"""Opportunity card ranking agent."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.research.feedback_agent import apply_feedback_to_card
|
||||||
|
from app.research.industry_chain_agent import infer_chain_node
|
||||||
|
|
||||||
|
|
||||||
|
def build_opportunity_cards(
|
||||||
|
recommendations: list[Any],
|
||||||
|
stock_notes: list[dict],
|
||||||
|
risk_alerts: list[dict],
|
||||||
|
feedback: dict | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
note_map = {item["ts_code"]: item for item in stock_notes}
|
||||||
|
rejected = {item["ts_code"] for item in risk_alerts if item.get("reject") and item.get("ts_code")}
|
||||||
|
risk_by_code: dict[str, list[dict]] = {}
|
||||||
|
global_risks: list[dict] = []
|
||||||
|
for risk in risk_alerts:
|
||||||
|
if risk.get("ts_code"):
|
||||||
|
risk_by_code.setdefault(risk["ts_code"], []).append(risk)
|
||||||
|
else:
|
||||||
|
global_risks.append(risk)
|
||||||
|
cards: list[dict] = []
|
||||||
|
for rec in recommendations:
|
||||||
|
ts_code = getattr(rec, "ts_code", "")
|
||||||
|
if ts_code in rejected:
|
||||||
|
continue
|
||||||
|
note = note_map.get(ts_code, {})
|
||||||
|
action_plan = getattr(rec, "action_plan", "观察") or "观察"
|
||||||
|
if action_plan == "可操作":
|
||||||
|
opportunity_type = "可操作"
|
||||||
|
elif action_plan == "重点关注":
|
||||||
|
opportunity_type = "等确认"
|
||||||
|
else:
|
||||||
|
hint = ((getattr(rec, "decision_trace", {}) or {}).get("position_adjustment") or {}).get("hint", "")
|
||||||
|
opportunity_type = "等回踩" if hint == "wait_pullback" else "仅观察"
|
||||||
|
theme = getattr(rec, "sector", "") or note.get("theme") or "未归类"
|
||||||
|
alpha_profile = _build_alpha_profile(rec, note, risk_by_code.get(ts_code, []) + global_risks)
|
||||||
|
card = {
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"name": getattr(rec, "name", ""),
|
||||||
|
"theme": theme,
|
||||||
|
"chain_node": note.get("chain_node") or infer_chain_node(theme, getattr(rec, "name", ""), theme),
|
||||||
|
"stock_role": note.get("stock_role", "待归类"),
|
||||||
|
"opportunity_type": opportunity_type,
|
||||||
|
"score": round(float(getattr(rec, "score", 0) or 0), 1),
|
||||||
|
"logic_score": note.get("logic_score", 0),
|
||||||
|
"action_plan": action_plan,
|
||||||
|
"trigger": getattr(rec, "trigger_condition", "") or getattr(rec, "entry_timing", ""),
|
||||||
|
"invalid_condition": note.get("invalid_condition") or getattr(rec, "invalidation_condition", "") or getattr(rec, "risk_note", ""),
|
||||||
|
"logic_summary": note.get("logic_summary", ""),
|
||||||
|
**alpha_profile,
|
||||||
|
}
|
||||||
|
cards.append(apply_feedback_to_card(card, rec, risk_by_code.get(ts_code, []) + global_risks, feedback))
|
||||||
|
cards.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
{"可操作": 3, "等确认": 2, "等回踩": 1, "仅观察": 0}.get(item["opportunity_type"], 0),
|
||||||
|
item.get("alpha_score", 0),
|
||||||
|
item.get("ambush_score", 0),
|
||||||
|
item.get("adjusted_score", item["score"]),
|
||||||
|
item["logic_score"],
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return cards[:20]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_alpha_profile(rec: Any, note: dict, risks: list[dict]) -> dict:
|
||||||
|
trace = getattr(rec, "decision_trace", {}) or {}
|
||||||
|
position = trace.get("position_adjustment") or {}
|
||||||
|
hint = position.get("hint", "")
|
||||||
|
action_plan = getattr(rec, "action_plan", "观察") or "观察"
|
||||||
|
score = float(getattr(rec, "score", 0) or 0)
|
||||||
|
market_score = float(getattr(rec, "market_temp_score", 0) or 0)
|
||||||
|
sector_score = float(getattr(rec, "sector_score", 0) or 0)
|
||||||
|
capital_score = float(getattr(rec, "capital_score", 0) or 0)
|
||||||
|
technical_score = float(getattr(rec, "technical_score", 0) or 0)
|
||||||
|
valuation_score = float(getattr(rec, "valuation_score", 0) or 0)
|
||||||
|
position_score = float(getattr(rec, "position_score", 0) or 0)
|
||||||
|
logic_score = float(note.get("logic_score", 0) or 0)
|
||||||
|
risk_penalty = min(sum(18 if item.get("reject") else 7 for item in risks), 35)
|
||||||
|
|
||||||
|
beta_dependency_score = _clamp(
|
||||||
|
20
|
||||||
|
+ market_score * 0.55
|
||||||
|
+ max(0, sector_score - capital_score) * 0.2
|
||||||
|
- min(capital_score, 20) * 0.15
|
||||||
|
)
|
||||||
|
alpha_score = _clamp(
|
||||||
|
score * 0.25
|
||||||
|
+ sector_score * 0.18
|
||||||
|
+ capital_score * 0.22
|
||||||
|
+ technical_score * 0.16
|
||||||
|
+ logic_score * 0.14
|
||||||
|
+ valuation_score * 0.08
|
||||||
|
- beta_dependency_score * 0.12
|
||||||
|
- risk_penalty
|
||||||
|
)
|
||||||
|
ambush_score = _clamp(
|
||||||
|
position_score * 0.26
|
||||||
|
+ valuation_score * 0.22
|
||||||
|
+ capital_score * 0.18
|
||||||
|
+ technical_score * 0.16
|
||||||
|
+ (12 if hint in {"wait_confirm", "actionable_pullback", "wait_pullback"} else 0)
|
||||||
|
+ (8 if action_plan in {"重点关注", "观察"} else 0)
|
||||||
|
- risk_penalty * 0.7
|
||||||
|
)
|
||||||
|
expectation_gap_score = _clamp(
|
||||||
|
logic_score * 0.28
|
||||||
|
+ sector_score * 0.2
|
||||||
|
+ capital_score * 0.2
|
||||||
|
+ valuation_score * 0.16
|
||||||
|
+ (10 if note.get("generated_by") == "llm" else 4)
|
||||||
|
- market_score * 0.1
|
||||||
|
- risk_penalty * 0.6
|
||||||
|
)
|
||||||
|
|
||||||
|
risk_gate = _risk_gate(risks)
|
||||||
|
alpha_type = _alpha_type(action_plan, hint, ambush_score, expectation_gap_score, beta_dependency_score)
|
||||||
|
beta_dependency = _beta_label(beta_dependency_score)
|
||||||
|
setup_quality = _setup_quality(alpha_score, ambush_score, risk_gate)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"alpha_type": alpha_type,
|
||||||
|
"alpha_score": round(alpha_score, 1),
|
||||||
|
"beta_dependency": beta_dependency,
|
||||||
|
"beta_dependency_score": round(beta_dependency_score, 1),
|
||||||
|
"ambush_score": round(ambush_score, 1),
|
||||||
|
"expectation_gap_score": round(expectation_gap_score, 1),
|
||||||
|
"risk_gate": risk_gate,
|
||||||
|
"setup_quality": setup_quality,
|
||||||
|
"alpha_reason": _alpha_reason(alpha_type, beta_dependency, ambush_score, expectation_gap_score, risk_gate),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _alpha_type(action_plan: str, hint: str, ambush_score: float, expectation_gap_score: float, beta_dependency_score: float) -> str:
|
||||||
|
if ambush_score >= 70 and action_plan != "可操作":
|
||||||
|
return "低位埋伏"
|
||||||
|
if hint == "wait_pullback":
|
||||||
|
return "强势等回踩"
|
||||||
|
if hint == "wait_confirm" or action_plan == "重点关注":
|
||||||
|
return "等确认"
|
||||||
|
if expectation_gap_score >= 72 and beta_dependency_score <= 65:
|
||||||
|
return "预期差机会"
|
||||||
|
if action_plan == "可操作":
|
||||||
|
return "趋势确认"
|
||||||
|
return "观察线索"
|
||||||
|
|
||||||
|
|
||||||
|
def _risk_gate(risks: list[dict]) -> str:
|
||||||
|
if any(item.get("reject") for item in risks):
|
||||||
|
return "否决"
|
||||||
|
if risks:
|
||||||
|
return "预警"
|
||||||
|
return "通过"
|
||||||
|
|
||||||
|
|
||||||
|
def _beta_label(score: float) -> str:
|
||||||
|
if score >= 70:
|
||||||
|
return "高"
|
||||||
|
if score >= 45:
|
||||||
|
return "中"
|
||||||
|
return "低"
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_quality(alpha_score: float, ambush_score: float, risk_gate: str) -> str:
|
||||||
|
if risk_gate == "否决":
|
||||||
|
return "不参与"
|
||||||
|
if alpha_score >= 78 and ambush_score >= 65:
|
||||||
|
return "优先研究"
|
||||||
|
if alpha_score >= 65 or ambush_score >= 70:
|
||||||
|
return "可跟踪"
|
||||||
|
return "仅观察"
|
||||||
|
|
||||||
|
|
||||||
|
def _alpha_reason(alpha_type: str, beta_dependency: str, ambush_score: float, expectation_gap_score: float, risk_gate: str) -> str:
|
||||||
|
if risk_gate == "否决":
|
||||||
|
return "风险门槛未通过,先排除。"
|
||||||
|
parts = [f"{alpha_type},Beta依赖{beta_dependency}"]
|
||||||
|
if ambush_score >= 70:
|
||||||
|
parts.append("位置更适合提前跟踪")
|
||||||
|
if expectation_gap_score >= 70:
|
||||||
|
parts.append("存在预期差")
|
||||||
|
if risk_gate == "预警":
|
||||||
|
parts.append("但有风险预警")
|
||||||
|
return ";".join(parts) + "。"
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp(value: float, low: float = 0, high: float = 100) -> float:
|
||||||
|
return max(low, min(high, value))
|
||||||
354
backend/app/research/report_agent.py
Normal file
354
backend/app/research/report_agent.py
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
"""Build and persist daily AI research reports."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.db import tables
|
||||||
|
from app.research.catalyst_agent import build_catalyst_summary
|
||||||
|
from app.research.feedback_agent import build_ranking_feedback
|
||||||
|
from app.research.market_agent import build_market_view
|
||||||
|
from app.research.ranking_agent import build_opportunity_cards
|
||||||
|
from app.research.risk_agent import build_risk_alerts
|
||||||
|
from app.research.sector_agent import build_theme_views, build_theme_views_async
|
||||||
|
from app.research.stock_research_agent import build_stock_research_notes, build_stock_research_notes_sync
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_scan_from_result(result: dict, scan_session: str) -> dict:
|
||||||
|
for item in result.get("scan_logs", []) or []:
|
||||||
|
if item.get("stage") == "final_filter":
|
||||||
|
return item
|
||||||
|
return {
|
||||||
|
"scan_session": scan_session,
|
||||||
|
"scan_mode": result.get("scan_mode", ""),
|
||||||
|
"status": "empty" if not result.get("recommendations") else "ok",
|
||||||
|
"summary": "",
|
||||||
|
"elimination_reasons": {},
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_research_report(result: dict, scan_session: str) -> dict:
|
||||||
|
"""Build a report with deterministic stock notes.
|
||||||
|
|
||||||
|
This remains available for fast API fallbacks and tests. Normal scans use
|
||||||
|
build_research_report_async so the stock research notes can call the LLM.
|
||||||
|
"""
|
||||||
|
return _assemble_research_report(
|
||||||
|
result,
|
||||||
|
scan_session,
|
||||||
|
build_stock_research_notes_sync,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_research_report_async(result: dict, scan_session: str) -> dict:
|
||||||
|
return await _assemble_research_report_async(result, scan_session)
|
||||||
|
|
||||||
|
|
||||||
|
def _common_inputs(result: dict, scan_session: str) -> tuple[Any, dict, list, list, dict, dict, list, dict, list]:
|
||||||
|
market_temp = result.get("market_temp")
|
||||||
|
strategy_profile = result.get("strategy_profile") or {}
|
||||||
|
sectors = result.get("hot_sectors", []) or []
|
||||||
|
recommendations = result.get("recommendations", []) or []
|
||||||
|
latest_scan = result.get("latest_scan") or _latest_scan_from_result(result, scan_session)
|
||||||
|
|
||||||
|
theme_views = build_theme_views(sectors)
|
||||||
|
_calibrate_market_from_themes(market_temp, theme_views)
|
||||||
|
market_view = build_market_view(market_temp, strategy_profile)
|
||||||
|
catalyst = build_catalyst_summary(theme_views)
|
||||||
|
risks = build_risk_alerts(recommendations, market_view, latest_scan)
|
||||||
|
return market_temp, strategy_profile, sectors, recommendations, latest_scan, market_view, theme_views, catalyst, risks
|
||||||
|
|
||||||
|
|
||||||
|
def _assemble_research_report(result: dict, scan_session: str, notes_builder) -> dict:
|
||||||
|
market_temp, _, _, recommendations, latest_scan, market_view, theme_views, catalyst, risks = _common_inputs(result, scan_session)
|
||||||
|
stock_notes = notes_builder(recommendations, theme_views)
|
||||||
|
return _finalize_research_report(result, scan_session, market_temp, recommendations, latest_scan, market_view, theme_views, catalyst, risks, stock_notes, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _assemble_research_report_async(result: dict, scan_session: str) -> dict:
|
||||||
|
market_temp = result.get("market_temp")
|
||||||
|
strategy_profile = result.get("strategy_profile") or {}
|
||||||
|
sectors = result.get("hot_sectors", []) or []
|
||||||
|
recommendations = result.get("recommendations", []) or []
|
||||||
|
latest_scan = result.get("latest_scan") or _latest_scan_from_result(result, scan_session)
|
||||||
|
|
||||||
|
theme_views = await build_theme_views_async(sectors)
|
||||||
|
_calibrate_market_from_themes(market_temp, theme_views)
|
||||||
|
market_view = build_market_view(market_temp, strategy_profile)
|
||||||
|
catalyst = build_catalyst_summary(theme_views)
|
||||||
|
risks = build_risk_alerts(recommendations, market_view, latest_scan)
|
||||||
|
stock_notes = await build_stock_research_notes(recommendations, theme_views, risks)
|
||||||
|
feedback = await build_ranking_feedback(days=60)
|
||||||
|
data_quality = await _build_data_quality_report(market_view)
|
||||||
|
return _finalize_research_report(result, scan_session, market_temp, recommendations, latest_scan, market_view, theme_views, catalyst, risks, stock_notes, feedback, data_quality)
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_research_report(
|
||||||
|
result: dict,
|
||||||
|
scan_session: str,
|
||||||
|
market_temp: Any,
|
||||||
|
recommendations: list,
|
||||||
|
latest_scan: dict,
|
||||||
|
market_view: dict,
|
||||||
|
theme_views: list[dict],
|
||||||
|
catalyst: dict,
|
||||||
|
risks: list[dict],
|
||||||
|
stock_notes: list[dict],
|
||||||
|
feedback: dict | None,
|
||||||
|
data_quality: dict | None,
|
||||||
|
) -> dict:
|
||||||
|
opportunities = build_opportunity_cards(recommendations, stock_notes, risks, feedback)
|
||||||
|
trade_date = getattr(market_temp, "trade_date", "") or datetime.now().strftime("%Y%m%d")
|
||||||
|
|
||||||
|
if opportunities:
|
||||||
|
no_trade_reason = {"has_scan": True, "reason": "", "blocked_by": []}
|
||||||
|
else:
|
||||||
|
elimination = latest_scan.get("elimination_reasons") or {}
|
||||||
|
if elimination:
|
||||||
|
reason = ";".join(f"{k} {v}只" for k, v in list(elimination.items())[:3])
|
||||||
|
elif recommendations:
|
||||||
|
reason = "候选存在,但机会卡被风险或动作分层过滤。"
|
||||||
|
else:
|
||||||
|
reason = "本轮扫描没有形成满足条件的交易候选。"
|
||||||
|
no_trade_reason = {"has_scan": True, "reason": reason, "blocked_by": list(elimination.keys())[:5]}
|
||||||
|
|
||||||
|
top_theme_names = [item["theme"] for item in theme_views[:3]]
|
||||||
|
report = {
|
||||||
|
"trade_date": trade_date,
|
||||||
|
"scan_session": scan_session,
|
||||||
|
"scan_mode": result.get("scan_mode", ""),
|
||||||
|
"scanned_at": datetime.now().isoformat(),
|
||||||
|
"market_view": market_view,
|
||||||
|
"theme_views": theme_views,
|
||||||
|
"industry_chain_map": [
|
||||||
|
{
|
||||||
|
"theme": item["theme"],
|
||||||
|
"chain_nodes": item["chain_nodes"],
|
||||||
|
"chain_items": item.get("chain_items", []),
|
||||||
|
"leader_stocks": item.get("leader_stocks", []),
|
||||||
|
}
|
||||||
|
for item in theme_views
|
||||||
|
],
|
||||||
|
"catalyst": catalyst,
|
||||||
|
"stock_research_notes": stock_notes,
|
||||||
|
"opportunity_cards": opportunities,
|
||||||
|
"risk_alerts": risks,
|
||||||
|
"ranking_feedback": feedback or {},
|
||||||
|
"risk_summary": {
|
||||||
|
"reject_count": sum(1 for item in risks if item.get("reject")),
|
||||||
|
"warning_count": sum(1 for item in risks if not item.get("reject")),
|
||||||
|
"types": sorted({item.get("risk_type", "") for item in risks if item.get("risk_type")}),
|
||||||
|
},
|
||||||
|
"data_quality": data_quality or _fallback_data_quality(market_view),
|
||||||
|
"no_trade_reason": no_trade_reason,
|
||||||
|
"summary": {
|
||||||
|
"market": market_view["summary"],
|
||||||
|
"theme": f"当前主线关注 {'、'.join(top_theme_names)}。" if top_theme_names else "暂未形成清晰主线。",
|
||||||
|
"opportunity_count": len(opportunities),
|
||||||
|
"risk_count": len(risks),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_data_quality_report(market_view: dict) -> dict:
|
||||||
|
watched_sources = ("eastmoney", "tencent", "market_breadth")
|
||||||
|
since = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
issues = []
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT source, level, message, created_at FROM error_logs "
|
||||||
|
"WHERE created_at >= :since AND ("
|
||||||
|
"source LIKE '%eastmoney%' OR source LIKE '%tencent%' OR source = 'market_breadth'"
|
||||||
|
") ORDER BY created_at DESC, id DESC LIMIT 20"
|
||||||
|
),
|
||||||
|
{"since": since.strftime("%Y-%m-%d %H:%M:%S")},
|
||||||
|
)
|
||||||
|
for row in result.fetchall():
|
||||||
|
item = dict(row._mapping)
|
||||||
|
issues.append({
|
||||||
|
"source": item.get("source", ""),
|
||||||
|
"level": item.get("level", ""),
|
||||||
|
"message": item.get("message", ""),
|
||||||
|
"created_at": str(item.get("created_at") or ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
warnings = []
|
||||||
|
market_status = market_view.get("data_status") or "fresh"
|
||||||
|
if market_status == "estimated":
|
||||||
|
warnings.append("全市场涨跌停使用实时行情阈值估算,非涨跌停池精确口径。")
|
||||||
|
if issues:
|
||||||
|
warnings.append("今日存在行情源失败记录,盘中结论已降级处理。")
|
||||||
|
|
||||||
|
status = "degraded" if warnings or issues else "ok"
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"market_data_status": market_status,
|
||||||
|
"market_source": market_view.get("source", ""),
|
||||||
|
"limit_counts_reliable": bool(market_view.get("limit_counts_reliable", False)),
|
||||||
|
"warnings": warnings,
|
||||||
|
"issues": issues[:8],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_data_quality(market_view: dict) -> dict:
|
||||||
|
market_status = market_view.get("data_status") or "fresh"
|
||||||
|
warnings = []
|
||||||
|
if market_status == "estimated":
|
||||||
|
warnings.append("全市场涨跌停使用实时行情阈值估算。")
|
||||||
|
return {
|
||||||
|
"status": "degraded" if warnings else "ok",
|
||||||
|
"market_data_status": market_status,
|
||||||
|
"market_source": market_view.get("source", ""),
|
||||||
|
"limit_counts_reliable": bool(market_view.get("limit_counts_reliable", False)),
|
||||||
|
"warnings": warnings,
|
||||||
|
"issues": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _calibrate_market_from_themes(market_temp: Any, theme_views: list[dict]) -> None:
|
||||||
|
if not market_temp or not theme_views:
|
||||||
|
return
|
||||||
|
theme_limit_up = sum(max(int(item.get("limit_up_count") or 0), 0) for item in theme_views)
|
||||||
|
if theme_limit_up <= int(getattr(market_temp, "limit_up_count", 0) or 0):
|
||||||
|
return
|
||||||
|
original_limit = int(getattr(market_temp, "limit_up_count", 0) or 0)
|
||||||
|
original_temp = float(getattr(market_temp, "temperature", 0) or 0)
|
||||||
|
market_temp.limit_up_count = theme_limit_up
|
||||||
|
if original_limit == 0:
|
||||||
|
market_temp.temperature = round(min(original_temp + min(theme_limit_up / 5, 8), 100), 1)
|
||||||
|
if not getattr(market_temp, "limit_counts_reliable", False):
|
||||||
|
market_temp.data_status = "estimated"
|
||||||
|
detail = getattr(market_temp, "source_detail", "") or ""
|
||||||
|
market_temp.source_detail = f"{detail};theme_limit_lower_bound"
|
||||||
|
|
||||||
|
|
||||||
|
async def save_research_report(report: dict) -> None:
|
||||||
|
trade_date = str(report.get("trade_date") or "")
|
||||||
|
scan_session = str(report.get("scan_session") or "manual")
|
||||||
|
now = datetime.now()
|
||||||
|
async with get_db() as db:
|
||||||
|
await db.execute(text("DELETE FROM research_reports WHERE scan_session = :session"), {"session": scan_session})
|
||||||
|
await db.execute(text("DELETE FROM theme_maps WHERE scan_session = :session"), {"session": scan_session})
|
||||||
|
await db.execute(text("DELETE FROM theme_chain_nodes WHERE scan_session = :session"), {"session": scan_session})
|
||||||
|
await db.execute(text("DELETE FROM stock_research_notes WHERE scan_session = :session"), {"session": scan_session})
|
||||||
|
await db.execute(text("DELETE FROM risk_events WHERE scan_session = :session"), {"session": scan_session})
|
||||||
|
await db.execute(text("DELETE FROM opportunity_cards WHERE scan_session = :session"), {"session": scan_session})
|
||||||
|
|
||||||
|
await db.execute(tables.research_reports_table.insert().values(
|
||||||
|
scan_session=scan_session,
|
||||||
|
trade_date=trade_date,
|
||||||
|
market_summary=report.get("summary", {}).get("market", ""),
|
||||||
|
theme_summary=report.get("summary", {}).get("theme", ""),
|
||||||
|
no_trade_reason=json.dumps(report.get("no_trade_reason", {}), ensure_ascii=False),
|
||||||
|
report_json=json.dumps(report, ensure_ascii=False, default=str),
|
||||||
|
created_at=now,
|
||||||
|
))
|
||||||
|
|
||||||
|
for theme in report.get("theme_views", []):
|
||||||
|
await db.execute(tables.theme_maps_table.insert().values(
|
||||||
|
scan_session=scan_session,
|
||||||
|
trade_date=trade_date,
|
||||||
|
theme_name=theme.get("theme", ""),
|
||||||
|
stage=theme.get("stage", ""),
|
||||||
|
heat_score=theme.get("heat_score", 0),
|
||||||
|
logic_summary=theme.get("logic", ""),
|
||||||
|
lifecycle_status=theme.get("lifecycle_status", ""),
|
||||||
|
created_at=now,
|
||||||
|
))
|
||||||
|
for node in theme.get("chain_nodes", []) or ["未归类"]:
|
||||||
|
chain_item = _chain_item_for_node(theme, node)
|
||||||
|
await db.execute(tables.theme_chain_nodes_table.insert().values(
|
||||||
|
scan_session=scan_session,
|
||||||
|
trade_date=trade_date,
|
||||||
|
theme_name=theme.get("theme", ""),
|
||||||
|
chain_node=node,
|
||||||
|
related_stocks=json.dumps(chain_item.get("related_stocks", []), ensure_ascii=False, default=str),
|
||||||
|
leader_stocks=json.dumps(chain_item.get("leader_stocks", []) or theme.get("leader_stocks", []), ensure_ascii=False, default=str),
|
||||||
|
created_at=now,
|
||||||
|
))
|
||||||
|
|
||||||
|
for note in report.get("stock_research_notes", []):
|
||||||
|
await db.execute(tables.stock_research_notes_table.insert().values(
|
||||||
|
scan_session=scan_session,
|
||||||
|
trade_date=trade_date,
|
||||||
|
ts_code=note.get("ts_code", ""),
|
||||||
|
name=note.get("name", ""),
|
||||||
|
theme=note.get("theme", "未归类"),
|
||||||
|
chain_node=note.get("chain_node", "未归类"),
|
||||||
|
logic_score=note.get("logic_score", 0),
|
||||||
|
logic_summary=note.get("logic_summary", ""),
|
||||||
|
evidence_json=json.dumps(note.get("evidence", []), ensure_ascii=False, default=str),
|
||||||
|
uncertainty=note.get("uncertainty", ""),
|
||||||
|
stock_role=note.get("stock_role", "待归类"),
|
||||||
|
disagreement=note.get("disagreement", ""),
|
||||||
|
invalid_condition=note.get("invalid_condition", ""),
|
||||||
|
generated_by=note.get("generated_by", "rules"),
|
||||||
|
created_at=now,
|
||||||
|
))
|
||||||
|
|
||||||
|
for risk in report.get("risk_alerts", []):
|
||||||
|
await db.execute(tables.risk_events_table.insert().values(
|
||||||
|
scan_session=scan_session,
|
||||||
|
trade_date=trade_date,
|
||||||
|
ts_code=risk.get("ts_code", ""),
|
||||||
|
risk_type=risk.get("risk_type", ""),
|
||||||
|
severity=risk.get("severity", "warning"),
|
||||||
|
reject=bool(risk.get("reject")),
|
||||||
|
reason=risk.get("reason", ""),
|
||||||
|
source=risk.get("source", "research_agent"),
|
||||||
|
created_at=now,
|
||||||
|
))
|
||||||
|
|
||||||
|
for card in report.get("opportunity_cards", []):
|
||||||
|
await db.execute(tables.opportunity_cards_table.insert().values(
|
||||||
|
scan_session=scan_session,
|
||||||
|
trade_date=trade_date,
|
||||||
|
ts_code=card.get("ts_code", ""),
|
||||||
|
name=card.get("name", ""),
|
||||||
|
theme=card.get("theme", ""),
|
||||||
|
chain_node=card.get("chain_node", "未归类"),
|
||||||
|
stock_role=card.get("stock_role", "待归类"),
|
||||||
|
opportunity_type=card.get("opportunity_type", "观察"),
|
||||||
|
score=card.get("adjusted_score", card.get("score", 0)),
|
||||||
|
alpha_type=card.get("alpha_type", "观察线索"),
|
||||||
|
alpha_score=card.get("alpha_score", 0),
|
||||||
|
beta_dependency=card.get("beta_dependency", "中"),
|
||||||
|
beta_dependency_score=card.get("beta_dependency_score", 0),
|
||||||
|
ambush_score=card.get("ambush_score", 0),
|
||||||
|
expectation_gap_score=card.get("expectation_gap_score", 0),
|
||||||
|
risk_gate=card.get("risk_gate", "通过"),
|
||||||
|
setup_quality=card.get("setup_quality", "仅观察"),
|
||||||
|
alpha_reason=card.get("alpha_reason", ""),
|
||||||
|
action_plan=card.get("action_plan", "观察"),
|
||||||
|
trigger=card.get("trigger", ""),
|
||||||
|
invalid_condition=card.get("invalid_condition", ""),
|
||||||
|
created_at=now,
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _chain_item_for_node(theme: dict, node: str) -> dict:
|
||||||
|
for item in theme.get("chain_items", []) or []:
|
||||||
|
if item.get("chain_node") == node:
|
||||||
|
return item
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def load_latest_research_report() -> dict | None:
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(text("SELECT report_json FROM research_reports ORDER BY created_at DESC, id DESC LIMIT 1"))
|
||||||
|
row = result.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(row._mapping["report_json"] or "{}")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
187
backend/app/research/review_agent.py
Normal file
187
backend/app/research/review_agent.py
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
"""Research review aggregation.
|
||||||
|
|
||||||
|
This module connects recommendation tracking with the new research layer, so
|
||||||
|
we can review which themes, chain nodes, signals and risks are working.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.db.database import get_db
|
||||||
|
|
||||||
|
|
||||||
|
async def build_research_review(days: int = 60) -> dict:
|
||||||
|
start = (datetime.now() - timedelta(days=max(1, days))).strftime("%Y-%m-%d")
|
||||||
|
rows = await _load_review_rows(start)
|
||||||
|
theme_rows = _breakdown(rows, "theme")
|
||||||
|
chain_rows = _breakdown(rows, "chain_node")
|
||||||
|
signal_rows = _breakdown(rows, "entry_signal_type")
|
||||||
|
risk_rows = _risk_breakdown(rows)
|
||||||
|
summary = _summary(rows, theme_rows, chain_rows, signal_rows, risk_rows)
|
||||||
|
return {
|
||||||
|
"days": days,
|
||||||
|
"sample_count": len(rows),
|
||||||
|
"tracked_count": sum(1 for item in rows if item.get("pct_from_entry") is not None),
|
||||||
|
"theme_breakdown": theme_rows,
|
||||||
|
"chain_breakdown": chain_rows,
|
||||||
|
"signal_breakdown": signal_rows,
|
||||||
|
"risk_breakdown": risk_rows,
|
||||||
|
"summary": summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_review_rows(start: str) -> list[dict[str, Any]]:
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"WITH latest_tracking AS ("
|
||||||
|
" SELECT t.* FROM recommendation_tracking t "
|
||||||
|
" INNER JOIN ("
|
||||||
|
" SELECT recommendation_id, MAX(id) AS max_id "
|
||||||
|
" FROM recommendation_tracking GROUP BY recommendation_id"
|
||||||
|
" ) lt ON t.id = lt.max_id"
|
||||||
|
"), latest_notes AS ("
|
||||||
|
" SELECT n.* FROM stock_research_notes n "
|
||||||
|
" INNER JOIN ("
|
||||||
|
" SELECT ts_code, MAX(id) AS max_id "
|
||||||
|
" FROM stock_research_notes GROUP BY ts_code"
|
||||||
|
" ) ln ON n.id = ln.max_id"
|
||||||
|
"), risk_summary AS ("
|
||||||
|
" SELECT ts_code, "
|
||||||
|
" GROUP_CONCAT(DISTINCT risk_type) AS risk_types, "
|
||||||
|
" MAX(CASE WHEN reject = 1 THEN 1 ELSE 0 END) AS rejected "
|
||||||
|
" FROM risk_events GROUP BY ts_code"
|
||||||
|
") "
|
||||||
|
"SELECT r.id, r.ts_code, r.name, r.sector, r.entry_signal_type, r.action_plan, "
|
||||||
|
" r.lifecycle_status, r.score, r.created_at, "
|
||||||
|
" lt.pct_from_entry, lt.max_return_pct, lt.max_drawdown_pct, lt.hit_target, "
|
||||||
|
" lt.hit_stop_loss, lt.close_reason, lt.review_note, "
|
||||||
|
" COALESCE(n.theme, r.sector, '未归类') AS theme, "
|
||||||
|
" COALESCE(n.chain_node, '未归类') AS chain_node, "
|
||||||
|
" COALESCE(n.stock_role, '待归类') AS stock_role, "
|
||||||
|
" COALESCE(n.logic_score, 0) AS logic_score, "
|
||||||
|
" COALESCE(rs.risk_types, '') AS risk_types, "
|
||||||
|
" COALESCE(rs.rejected, 0) AS risk_rejected "
|
||||||
|
"FROM recommendations r "
|
||||||
|
"LEFT JOIN latest_tracking lt ON lt.recommendation_id = r.id "
|
||||||
|
"LEFT JOIN latest_notes n ON n.ts_code = r.ts_code "
|
||||||
|
"LEFT JOIN risk_summary rs ON rs.ts_code = r.ts_code "
|
||||||
|
"WHERE r.created_at >= :start "
|
||||||
|
"ORDER BY r.created_at DESC, r.score DESC"
|
||||||
|
),
|
||||||
|
{"start": start},
|
||||||
|
)
|
||||||
|
return [dict(row._mapping) for row in result.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _breakdown(rows: list[dict[str, Any]], key: str) -> list[dict]:
|
||||||
|
groups: dict[str, list[dict]] = defaultdict(list)
|
||||||
|
for row in rows:
|
||||||
|
label = str(row.get(key) or "未归类")
|
||||||
|
groups[label].append(row)
|
||||||
|
return [_build_group(label, items) for label, items in groups.items() if label][:12]
|
||||||
|
|
||||||
|
|
||||||
|
def _risk_breakdown(rows: list[dict[str, Any]]) -> list[dict]:
|
||||||
|
groups: dict[str, list[dict]] = defaultdict(list)
|
||||||
|
for row in rows:
|
||||||
|
risk_types = [item for item in str(row.get("risk_types") or "").split(",") if item]
|
||||||
|
for risk_type in risk_types:
|
||||||
|
groups[risk_type].append(row)
|
||||||
|
result = [_build_group(label, items) for label, items in groups.items()]
|
||||||
|
return sorted(result, key=lambda item: (item["sample_count"], abs(item["avg_return"])), reverse=True)[:10]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_group(label: str, items: list[dict]) -> dict:
|
||||||
|
tracked = [item for item in items if item.get("pct_from_entry") is not None]
|
||||||
|
wins = [item for item in tracked if float(item.get("pct_from_entry") or 0) > 0]
|
||||||
|
avg_return = _avg(tracked, "pct_from_entry")
|
||||||
|
avg_max_return = _avg(tracked, "max_return_pct")
|
||||||
|
avg_drawdown = _avg(tracked, "max_drawdown_pct")
|
||||||
|
hit_target = sum(1 for item in tracked if item.get("hit_target"))
|
||||||
|
hit_stop = sum(1 for item in tracked if item.get("hit_stop_loss"))
|
||||||
|
return {
|
||||||
|
"label": label,
|
||||||
|
"sample_count": len(items),
|
||||||
|
"tracked_count": len(tracked),
|
||||||
|
"win_rate": round(len(wins) / len(tracked) * 100, 1) if tracked else 0,
|
||||||
|
"avg_return": round(avg_return, 2),
|
||||||
|
"avg_max_return": round(avg_max_return, 2),
|
||||||
|
"avg_drawdown": round(avg_drawdown, 2),
|
||||||
|
"hit_target_count": hit_target,
|
||||||
|
"hit_stop_count": hit_stop,
|
||||||
|
"effectiveness": _effectiveness(len(tracked), len(wins), avg_return, hit_stop),
|
||||||
|
"top_samples": [
|
||||||
|
{
|
||||||
|
"ts_code": item.get("ts_code"),
|
||||||
|
"name": item.get("name"),
|
||||||
|
"pct_from_entry": item.get("pct_from_entry"),
|
||||||
|
"max_return_pct": item.get("max_return_pct"),
|
||||||
|
"created_at": str(item.get("created_at") or "")[:10],
|
||||||
|
}
|
||||||
|
for item in sorted(tracked, key=lambda row: float(row.get("pct_from_entry") or 0), reverse=True)[:3]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _summary(
|
||||||
|
rows: list[dict],
|
||||||
|
themes: list[dict],
|
||||||
|
chains: list[dict],
|
||||||
|
signals: list[dict],
|
||||||
|
risks: list[dict],
|
||||||
|
) -> dict:
|
||||||
|
tracked = [item for item in rows if item.get("pct_from_entry") is not None]
|
||||||
|
strongest_theme = _first_by_effectiveness(themes)
|
||||||
|
strongest_chain = _first_by_effectiveness(chains)
|
||||||
|
strongest_signal = _first_by_effectiveness(signals)
|
||||||
|
weakest_risk = sorted(risks, key=lambda item: item["avg_return"])[:1]
|
||||||
|
headline = "等待形成研究复盘样本"
|
||||||
|
if tracked:
|
||||||
|
headline = f"近 {len(tracked)} 个跟踪样本,{strongest_theme.get('label', '主题')} 相对更有效"
|
||||||
|
return {
|
||||||
|
"headline": headline,
|
||||||
|
"strongest_theme": strongest_theme,
|
||||||
|
"strongest_chain": strongest_chain,
|
||||||
|
"strongest_signal": strongest_signal,
|
||||||
|
"weakest_risk": weakest_risk[0] if weakest_risk else {},
|
||||||
|
"suggestions": _suggestions(strongest_theme, strongest_chain, strongest_signal, weakest_risk[0] if weakest_risk else {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _first_by_effectiveness(items: list[dict]) -> dict:
|
||||||
|
eligible = [item for item in items if item["tracked_count"] >= 1]
|
||||||
|
if not eligible:
|
||||||
|
return {}
|
||||||
|
return sorted(eligible, key=lambda item: (item["effectiveness"], item["win_rate"], item["avg_return"]), reverse=True)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _suggestions(theme: dict, chain: dict, signal: dict, risk: dict) -> list[str]:
|
||||||
|
suggestions = []
|
||||||
|
if theme:
|
||||||
|
suggestions.append(f"提高 {theme['label']} 方向的复盘权重,当前胜率 {theme['win_rate']}%。")
|
||||||
|
if chain:
|
||||||
|
suggestions.append(f"优先跟踪 {chain['label']} 环节,平均收益 {chain['avg_return']}%。")
|
||||||
|
if signal:
|
||||||
|
suggestions.append(f"信号上关注 {signal['label']},减少低效入口。")
|
||||||
|
if risk and risk.get("avg_return", 0) < 0:
|
||||||
|
suggestions.append(f"命中 {risk['label']} 风险的样本表现偏弱,后续应降低排序或直接观察。")
|
||||||
|
return suggestions[:4] or ["样本不足,继续累积主题、环节和信号复盘数据。"]
|
||||||
|
|
||||||
|
|
||||||
|
def _effectiveness(tracked_count: int, wins: int, avg_return: float, hit_stop: int) -> float:
|
||||||
|
if tracked_count <= 0:
|
||||||
|
return 0
|
||||||
|
win_rate = wins / tracked_count * 100
|
||||||
|
stop_penalty = hit_stop / tracked_count * 18
|
||||||
|
return round(win_rate * 0.55 + avg_return * 2.5 - stop_penalty + min(tracked_count, 8), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _avg(rows: list[dict], key: str) -> float:
|
||||||
|
values = [float(item.get(key) or 0) for item in rows if item.get(key) is not None]
|
||||||
|
return sum(values) / len(values) if values else 0
|
||||||
334
backend/app/research/risk_agent.py
Normal file
334
backend/app/research/risk_agent.py
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
"""Risk research agent.
|
||||||
|
|
||||||
|
This v1 uses deterministic risk signals already present in the scan result.
|
||||||
|
External reduction/unlock/regulatory data can be added behind this interface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.data.tushare_client import tushare_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_risk_alerts(recommendations: list[Any], market_view: dict, latest_scan: dict | None = None) -> list[dict]:
|
||||||
|
alerts: list[dict] = []
|
||||||
|
if market_view.get("regime") in {"risk_off", "defensive_watch"}:
|
||||||
|
alerts.append({
|
||||||
|
"ts_code": "",
|
||||||
|
"risk_type": "market_regime",
|
||||||
|
"severity": "reject" if market_view.get("regime") == "risk_off" else "warning",
|
||||||
|
"reject": market_view.get("regime") == "risk_off",
|
||||||
|
"reason": market_view.get("summary", "市场环境偏弱"),
|
||||||
|
"source": "market_agent",
|
||||||
|
})
|
||||||
|
|
||||||
|
elimination = (latest_scan or {}).get("elimination_reasons") or {}
|
||||||
|
if elimination:
|
||||||
|
top_reason = sorted(elimination.items(), key=lambda item: item[1], reverse=True)[0]
|
||||||
|
alerts.append({
|
||||||
|
"ts_code": "",
|
||||||
|
"risk_type": "filter_pressure",
|
||||||
|
"severity": "warning",
|
||||||
|
"reject": False,
|
||||||
|
"reason": f"最终过滤压力较高:{top_reason[0]} {top_reason[1]} 只",
|
||||||
|
"source": "scan_process_logs",
|
||||||
|
})
|
||||||
|
|
||||||
|
for rec in recommendations[:20]:
|
||||||
|
trace = getattr(rec, "decision_trace", {}) or {}
|
||||||
|
position_hint = (trace.get("position_adjustment") or {}).get("hint", "")
|
||||||
|
if position_hint in {"wait_pullback", "wait_confirm"}:
|
||||||
|
alerts.append({
|
||||||
|
"ts_code": getattr(rec, "ts_code", ""),
|
||||||
|
"risk_type": "position_risk",
|
||||||
|
"severity": "warning",
|
||||||
|
"reject": False,
|
||||||
|
"reason": "位置或买点需要等待确认,避免追高。",
|
||||||
|
"source": "risk_agent",
|
||||||
|
})
|
||||||
|
|
||||||
|
if settings.research_risk_enabled:
|
||||||
|
alerts.extend(_build_external_stock_risks(recommendations[: settings.research_risk_stock_limit]))
|
||||||
|
|
||||||
|
return _dedupe_alerts(alerts)[:40]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_external_stock_risks(recommendations: list[Any]) -> list[dict]:
|
||||||
|
today = datetime.now()
|
||||||
|
today_s = today.strftime("%Y%m%d")
|
||||||
|
unlock_end = (today + timedelta(days=settings.risk_unlock_lookahead_days)).strftime("%Y%m%d")
|
||||||
|
holder_start = (today - timedelta(days=settings.risk_holder_trade_lookback_days)).strftime("%Y%m%d")
|
||||||
|
forecast_start = (today - timedelta(days=settings.risk_forecast_lookback_days)).strftime("%Y%m%d")
|
||||||
|
announcement_start = (today - timedelta(days=settings.risk_announcement_lookback_days)).strftime("%Y%m%d")
|
||||||
|
financial_start = (today - timedelta(days=settings.risk_financial_lookback_days)).strftime("%Y%m%d")
|
||||||
|
alerts: list[dict] = []
|
||||||
|
|
||||||
|
for rec in recommendations:
|
||||||
|
ts_code = getattr(rec, "ts_code", "")
|
||||||
|
name = getattr(rec, "name", "") or ts_code
|
||||||
|
if not ts_code:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
alerts.extend(_unlock_risks(ts_code, name, today_s, unlock_end))
|
||||||
|
alerts.extend(_holder_trade_risks(ts_code, name, holder_start, today_s))
|
||||||
|
alerts.extend(_forecast_risks(ts_code, name, forecast_start, today_s))
|
||||||
|
alerts.extend(_pledge_risks(ts_code, name))
|
||||||
|
alerts.extend(_announcement_risks(ts_code, name, announcement_start, today_s))
|
||||||
|
alerts.extend(_audit_risks(ts_code, name, financial_start, today_s))
|
||||||
|
alerts.extend(_financial_statement_risks(ts_code, name, financial_start, today_s))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("外部风险检查失败 %s: %s", ts_code, exc)
|
||||||
|
alerts.append({
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"risk_type": "risk_data_source",
|
||||||
|
"severity": "warning",
|
||||||
|
"reject": False,
|
||||||
|
"reason": f"{name} 外部风险数据读取失败,保留扫描内生风险判断。",
|
||||||
|
"source": "risk_agent",
|
||||||
|
})
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
|
_ANNOUNCEMENT_RISK_RULES: tuple[tuple[str, tuple[str, ...], str, bool], ...] = (
|
||||||
|
("regulatory", ("监管函", "问询函", "关注函", "警示函", "责令改正"), "warning", False),
|
||||||
|
("penalty", ("处罚", "行政处罚", "纪律处分", "公开谴责", "通报批评"), "reject", True),
|
||||||
|
("investigation", ("立案", "调查通知书", "涉嫌违法", "涉嫌信息披露违法"), "reject", True),
|
||||||
|
("litigation", ("重大诉讼", "重大仲裁", "诉讼进展", "仲裁进展"), "warning", False),
|
||||||
|
("asset_freeze", ("冻结", "轮候冻结", "司法冻结", "质押违约"), "reject", True),
|
||||||
|
("accounting_risk", ("会计差错", "前期差错", "非标准审计", "保留意见", "无法表示意见", "否定意见"), "reject", True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unlock_risks(ts_code: str, name: str, start: str, end: str) -> list[dict]:
|
||||||
|
df = tushare_client.get_share_float(ts_code, start, end)
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
alerts = []
|
||||||
|
for _, row in df.head(3).iterrows():
|
||||||
|
ratio = _float(row.get("float_ratio"))
|
||||||
|
shares = _float(row.get("float_share"))
|
||||||
|
date = str(row.get("float_date") or "")
|
||||||
|
holder = str(row.get("holder_name") or "限售股东")
|
||||||
|
if ratio >= settings.risk_unlock_reject_ratio:
|
||||||
|
severity, reject = "reject", True
|
||||||
|
elif ratio >= 5 or shares >= 5000:
|
||||||
|
severity, reject = "warning", False
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
alerts.append({
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"risk_type": "unlock",
|
||||||
|
"severity": severity,
|
||||||
|
"reject": reject,
|
||||||
|
"reason": f"{name} {date} 存在解禁,比例约{ratio:g}%({holder}),需规避供给冲击。",
|
||||||
|
"source": "tushare.share_float",
|
||||||
|
})
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
|
def _holder_trade_risks(ts_code: str, name: str, start: str, end: str) -> list[dict]:
|
||||||
|
df = tushare_client.get_holder_trade(ts_code, start, end)
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
alerts = []
|
||||||
|
for _, row in df.head(5).iterrows():
|
||||||
|
direction = str(row.get("in_de") or "")
|
||||||
|
ratio = abs(_float(row.get("change_ratio")))
|
||||||
|
volume = abs(_float(row.get("change_vol")))
|
||||||
|
ann_date = str(row.get("ann_date") or "")
|
||||||
|
holder = str(row.get("holder_name") or "股东")
|
||||||
|
is_reduce = "减" in direction or direction.upper() in {"D", "DECREASE"}
|
||||||
|
if not is_reduce:
|
||||||
|
continue
|
||||||
|
if ratio >= 2 or volume >= 3000:
|
||||||
|
severity, reject = "reject", ratio >= 5
|
||||||
|
alerts.append({
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"risk_type": "holder_reduce",
|
||||||
|
"severity": severity,
|
||||||
|
"reject": reject,
|
||||||
|
"reason": f"{name} {ann_date} 披露股东减持:{holder},变动比例约{ratio:g}%。",
|
||||||
|
"source": "tushare.stk_holdertrade",
|
||||||
|
})
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
|
def _forecast_risks(ts_code: str, name: str, start: str, end: str) -> list[dict]:
|
||||||
|
df = tushare_client.get_forecast(ts_code, start, end)
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
alerts = []
|
||||||
|
negative_keywords = ("预减", "亏损", "首亏", "续亏", "略减", "不确定")
|
||||||
|
for _, row in df.head(3).iterrows():
|
||||||
|
forecast_type = str(row.get("type") or "")
|
||||||
|
summary = str(row.get("summary") or row.get("change_reason") or "")
|
||||||
|
ann_date = str(row.get("ann_date") or "")
|
||||||
|
min_change = _float(row.get("p_change_min"))
|
||||||
|
max_change = _float(row.get("p_change_max"))
|
||||||
|
negative = any(word in forecast_type or word in summary for word in negative_keywords) or max_change < -20
|
||||||
|
if not negative:
|
||||||
|
continue
|
||||||
|
reject = forecast_type in {"首亏", "续亏"} or max_change < -50
|
||||||
|
alerts.append({
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"risk_type": "earnings_forecast",
|
||||||
|
"severity": "reject" if reject else "warning",
|
||||||
|
"reject": reject,
|
||||||
|
"reason": f"{name} {ann_date} 业绩预告偏负面:{forecast_type},变动区间约{min_change:g}%~{max_change:g}%。",
|
||||||
|
"source": "tushare.forecast",
|
||||||
|
})
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
|
def _pledge_risks(ts_code: str, name: str) -> list[dict]:
|
||||||
|
df = tushare_client.get_pledge_stat(ts_code)
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
row = df.iloc[-1]
|
||||||
|
ratio = _float(row.get("pledge_ratio"))
|
||||||
|
if ratio < 30:
|
||||||
|
return []
|
||||||
|
reject = ratio >= settings.risk_pledge_reject_ratio
|
||||||
|
return [{
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"risk_type": "pledge",
|
||||||
|
"severity": "reject" if reject else "warning",
|
||||||
|
"reject": reject,
|
||||||
|
"reason": f"{name} 股权质押比例约{ratio:g}%,需关注平仓和融资风险。",
|
||||||
|
"source": "tushare.pledge_stat",
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def _announcement_risks(ts_code: str, name: str, start: str, end: str) -> list[dict]:
|
||||||
|
df = tushare_client.get_announcements(ts_code, start, end)
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
alerts = []
|
||||||
|
for _, row in df.head(80).iterrows():
|
||||||
|
title = str(row.get("title") or "")
|
||||||
|
ann_date = str(row.get("ann_date") or "")
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
for risk_type, keywords, severity, reject in _ANNOUNCEMENT_RISK_RULES:
|
||||||
|
if any(keyword in title for keyword in keywords):
|
||||||
|
alerts.append({
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"risk_type": risk_type,
|
||||||
|
"severity": severity,
|
||||||
|
"reject": reject,
|
||||||
|
"reason": f"{name} {ann_date} 公告命中风险:{title[:90]}",
|
||||||
|
"source": "tushare.anns_d",
|
||||||
|
})
|
||||||
|
break
|
||||||
|
return alerts[:6]
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_risks(ts_code: str, name: str, start: str, end: str) -> list[dict]:
|
||||||
|
df = tushare_client.get_fina_audit(ts_code, start, end)
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
alerts = []
|
||||||
|
for _, row in df.tail(3).iterrows():
|
||||||
|
result = str(row.get("audit_result") or "")
|
||||||
|
ann_date = str(row.get("ann_date") or "")
|
||||||
|
agency = str(row.get("audit_agency") or "审计机构")
|
||||||
|
severity, reject = _classify_audit_opinion(result)
|
||||||
|
if not severity:
|
||||||
|
continue
|
||||||
|
alerts.append({
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"risk_type": "audit_opinion",
|
||||||
|
"severity": severity,
|
||||||
|
"reject": reject,
|
||||||
|
"reason": f"{name} {ann_date} 审计意见异常:{result}({agency})。",
|
||||||
|
"source": "tushare.fina_audit",
|
||||||
|
})
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_audit_opinion(result: str) -> tuple[str, bool]:
|
||||||
|
"""Return (severity, reject) for non-standard audit opinions.
|
||||||
|
|
||||||
|
Tushare often returns "标准无保留意见"; a plain substring check for
|
||||||
|
"保留意见" would incorrectly reject normal audit opinions.
|
||||||
|
"""
|
||||||
|
text = (result or "").strip()
|
||||||
|
if not text:
|
||||||
|
return "", False
|
||||||
|
normal_terms = ("标准无保留意见", "无保留意见")
|
||||||
|
warning_terms = ("带强调事项", "强调事项段", "非标准无保留")
|
||||||
|
reject_terms = ("无法表示", "否定意见", "保留意见", "非标")
|
||||||
|
if any(term in text for term in normal_terms) and not any(term in text for term in warning_terms):
|
||||||
|
return "", False
|
||||||
|
if any(term in text for term in ("无法表示", "否定意见", "非标")):
|
||||||
|
return "reject", True
|
||||||
|
if "保留意见" in text and not any(term in text for term in normal_terms):
|
||||||
|
return "reject", True
|
||||||
|
if any(term in text for term in warning_terms):
|
||||||
|
return "warning", False
|
||||||
|
return "", False
|
||||||
|
|
||||||
|
|
||||||
|
def _financial_statement_risks(ts_code: str, name: str, start: str, end: str) -> list[dict]:
|
||||||
|
df = tushare_client.get_balance_sheet(ts_code, start, end)
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
row = df.iloc[-1]
|
||||||
|
total_assets = _float(row.get("total_assets"))
|
||||||
|
total_liab = _float(row.get("total_liab"))
|
||||||
|
goodwill = _float(row.get("goodwill"))
|
||||||
|
end_date = str(row.get("end_date") or "")
|
||||||
|
alerts = []
|
||||||
|
if total_assets > 0:
|
||||||
|
goodwill_ratio = goodwill / total_assets * 100
|
||||||
|
if goodwill_ratio >= settings.risk_goodwill_assets_warning_ratio:
|
||||||
|
reject = goodwill_ratio >= settings.risk_goodwill_assets_reject_ratio
|
||||||
|
alerts.append({
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"risk_type": "goodwill",
|
||||||
|
"severity": "reject" if reject else "warning",
|
||||||
|
"reject": reject,
|
||||||
|
"reason": f"{name} {end_date} 商誉/总资产约{goodwill_ratio:.1f}%,需警惕减值风险。",
|
||||||
|
"source": "tushare.balancesheet",
|
||||||
|
})
|
||||||
|
debt_ratio = total_liab / total_assets * 100
|
||||||
|
if debt_ratio >= settings.risk_debt_assets_warning_ratio:
|
||||||
|
reject = debt_ratio >= settings.risk_debt_assets_reject_ratio
|
||||||
|
alerts.append({
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"risk_type": "debt_pressure",
|
||||||
|
"severity": "reject" if reject else "warning",
|
||||||
|
"reject": reject,
|
||||||
|
"reason": f"{name} {end_date} 资产负债率约{debt_ratio:.1f}%,需关注偿债压力。",
|
||||||
|
"source": "tushare.balancesheet",
|
||||||
|
})
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
|
def _float(value: Any) -> float:
|
||||||
|
try:
|
||||||
|
if value in (None, ""):
|
||||||
|
return 0.0
|
||||||
|
return float(value)
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_alerts(alerts: list[dict]) -> list[dict]:
|
||||||
|
seen = set()
|
||||||
|
result = []
|
||||||
|
severity_rank = {"reject": 2, "warning": 1, "info": 0}
|
||||||
|
alerts = sorted(alerts, key=lambda item: (severity_rank.get(item.get("severity", ""), 0), bool(item.get("reject"))), reverse=True)
|
||||||
|
for item in alerts:
|
||||||
|
key = (item.get("ts_code"), item.get("risk_type"), item.get("reason"))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
61
backend/app/research/sector_agent.py
Normal file
61
backend/app/research/sector_agent.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
"""Sector and theme research agent."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.research.industry_chain_agent import (
|
||||||
|
load_theme_chain_library,
|
||||||
|
map_sector_to_chain,
|
||||||
|
map_sector_to_chain_from_library,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_theme_views(sectors: list[Any]) -> list[dict]:
|
||||||
|
return _build_theme_views_with_mapper(sectors, map_sector_to_chain)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_theme_views_async(sectors: list[Any]) -> list[dict]:
|
||||||
|
library = await load_theme_chain_library()
|
||||||
|
|
||||||
|
def mapper(sector_name: str, leading_stocks: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||||
|
return map_sector_to_chain_from_library(sector_name, leading_stocks, library)
|
||||||
|
|
||||||
|
return _build_theme_views_with_mapper(sectors, mapper)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_theme_views_with_mapper(sectors: list[Any], mapper) -> list[dict]:
|
||||||
|
views: list[dict] = []
|
||||||
|
for sector in sectors[:10]:
|
||||||
|
pct = getattr(sector, "realtime_pct_change", None)
|
||||||
|
if pct is None:
|
||||||
|
pct = getattr(sector, "pct_change", 0) or 0
|
||||||
|
leading = getattr(sector, "leading_stocks_realtime", None) or getattr(sector, "leading_stocks", None) or []
|
||||||
|
chain = mapper(getattr(sector, "sector_name", ""), leading)
|
||||||
|
stage = getattr(sector, "stage", "") or chain.get("stage") or "mid"
|
||||||
|
heat = float(getattr(sector, "heat_score", 0) or 0)
|
||||||
|
theme = chain["theme"]
|
||||||
|
logic = chain["logic"]
|
||||||
|
views.append({
|
||||||
|
"theme": theme,
|
||||||
|
"raw_sector": getattr(sector, "sector_name", ""),
|
||||||
|
"stage": stage,
|
||||||
|
"heat_score": round(heat, 1),
|
||||||
|
"pct_change": round(float(pct or 0), 2),
|
||||||
|
"limit_up_count": int(getattr(sector, "limit_up_count", 0) or 0),
|
||||||
|
"logic": logic,
|
||||||
|
"chain_nodes": chain["chain_nodes"],
|
||||||
|
"chain_items": chain.get("chain_items", []),
|
||||||
|
"leader_stocks": leading[:5],
|
||||||
|
"lifecycle_status": chain.get("lifecycle_status") or _lifecycle_label(stage),
|
||||||
|
})
|
||||||
|
return views
|
||||||
|
|
||||||
|
|
||||||
|
def _lifecycle_label(stage: str) -> str:
|
||||||
|
return {
|
||||||
|
"early": "启动期",
|
||||||
|
"mid": "扩散期",
|
||||||
|
"late": "后段",
|
||||||
|
"end": "退潮",
|
||||||
|
}.get(stage, "观察期")
|
||||||
286
backend/app/research/stock_research_agent.py
Normal file
286
backend/app/research/stock_research_agent.py
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
"""Stock research note generator.
|
||||||
|
|
||||||
|
The stock research layer is explanatory. It enriches rule-selected candidates
|
||||||
|
with theme, catalyst and risk context, while deterministic notes remain the
|
||||||
|
fallback when the LLM or local catalyst data is unavailable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.llm.client import chat_completion
|
||||||
|
from app.research.industry_chain_agent import infer_chain_node, infer_chain_position_from_theme_view
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_stock_research_notes_sync(recommendations: list[Any], theme_views: list[dict]) -> list[dict]:
|
||||||
|
"""Build deterministic notes used by tests, fallback APIs and LLM failures."""
|
||||||
|
theme_names = {item["theme"] for item in theme_views}
|
||||||
|
notes: list[dict] = []
|
||||||
|
for rec in recommendations[:20]:
|
||||||
|
notes.append(_fallback_note(rec, theme_names, [], _match_theme_view(rec, theme_views)))
|
||||||
|
return notes
|
||||||
|
|
||||||
|
|
||||||
|
async def build_stock_research_notes(
|
||||||
|
recommendations: list[Any],
|
||||||
|
theme_views: list[dict],
|
||||||
|
risk_alerts: list[dict] | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Build stock research notes, using LLM for top candidates when configured."""
|
||||||
|
if not recommendations:
|
||||||
|
return []
|
||||||
|
|
||||||
|
theme_names = {item["theme"] for item in theme_views}
|
||||||
|
theme_map = {item["theme"]: item for item in theme_views}
|
||||||
|
risk_map = _group_risks(risk_alerts or [])
|
||||||
|
notes: list[dict] = []
|
||||||
|
llm_limit = max(0, int(settings.research_stock_llm_limit or 0))
|
||||||
|
|
||||||
|
for index, rec in enumerate(recommendations[:20]):
|
||||||
|
theme = getattr(rec, "sector", "") or "未归类"
|
||||||
|
theme_view = _match_theme_view(rec, theme_views)
|
||||||
|
catalysts = await _load_local_catalyst_context(rec, theme)
|
||||||
|
fallback = _fallback_note(rec, theme_names, catalysts, theme_view)
|
||||||
|
if (
|
||||||
|
not settings.research_stock_llm_enabled
|
||||||
|
or index >= llm_limit
|
||||||
|
or not settings.deepseek_api_key
|
||||||
|
):
|
||||||
|
notes.append(fallback)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
llm_note = await _build_llm_note(
|
||||||
|
rec=rec,
|
||||||
|
fallback=fallback,
|
||||||
|
theme_view=theme_view or theme_map.get(theme, {}),
|
||||||
|
risks=risk_map.get(getattr(rec, "ts_code", ""), []),
|
||||||
|
catalysts=catalysts,
|
||||||
|
)
|
||||||
|
notes.append(llm_note or fallback)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("股票研究笔记 LLM 生成失败 ts_code=%s error=%s", getattr(rec, "ts_code", ""), exc)
|
||||||
|
notes.append(fallback)
|
||||||
|
|
||||||
|
return notes
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_note(rec: Any, theme_names: set[str], catalysts: list[dict], theme_view: dict | None = None) -> dict:
|
||||||
|
trace = getattr(rec, "decision_trace", {}) or {}
|
||||||
|
evidence = trace.get("evidence") or getattr(rec, "reasons", []) or []
|
||||||
|
catalyst_titles = [item.get("title", "") for item in catalysts if item.get("title")]
|
||||||
|
theme = (theme_view or {}).get("theme") or getattr(rec, "sector", "") or "未归类"
|
||||||
|
position = (
|
||||||
|
infer_chain_position_from_theme_view(theme_view, getattr(rec, "ts_code", ""), getattr(rec, "name", ""))
|
||||||
|
if theme_view
|
||||||
|
else {"chain_node": infer_chain_node(theme, getattr(rec, "name", ""), getattr(rec, "sector", "")), "stock_role": "待归类"}
|
||||||
|
)
|
||||||
|
chain_node = position["chain_node"]
|
||||||
|
stock_role = position["stock_role"]
|
||||||
|
base = float(getattr(rec, "score", 0) or 0)
|
||||||
|
logic_score = min(100, base + (8 if theme in theme_names else 0) + min(len(catalyst_titles) * 1.5, 6))
|
||||||
|
action = getattr(rec, "action_plan", "观察") or "观察"
|
||||||
|
invalid = getattr(rec, "invalidation_condition", "") or getattr(rec, "risk_note", "") or "板块热度回落、资金持续性不足或买点未触发。"
|
||||||
|
return {
|
||||||
|
"ts_code": getattr(rec, "ts_code", ""),
|
||||||
|
"name": getattr(rec, "name", ""),
|
||||||
|
"theme": theme,
|
||||||
|
"chain_node": chain_node,
|
||||||
|
"stock_role": stock_role,
|
||||||
|
"logic_score": round(logic_score, 1),
|
||||||
|
"logic_summary": f"{getattr(rec, 'name', '')} 属于 {theme} 方向,产业链位置为{chain_node}({stock_role}),当前结论为{action}。",
|
||||||
|
"evidence": (evidence + catalyst_titles)[:5],
|
||||||
|
"uncertainty": getattr(rec, "risk_note", "") or "等待后续公告、资金持续性和板块生命周期验证。",
|
||||||
|
"disagreement": "若主线扩散失败、成交额无法维持或同板块核心股转弱,当前逻辑需要降级。",
|
||||||
|
"invalid_condition": invalid,
|
||||||
|
"generated_by": "rules",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_llm_note(
|
||||||
|
rec: Any,
|
||||||
|
fallback: dict,
|
||||||
|
theme_view: dict,
|
||||||
|
risks: list[dict],
|
||||||
|
catalysts: list[dict],
|
||||||
|
) -> dict | None:
|
||||||
|
payload = {
|
||||||
|
"stock": {
|
||||||
|
"ts_code": fallback["ts_code"],
|
||||||
|
"name": fallback["name"],
|
||||||
|
"theme": fallback["theme"],
|
||||||
|
"chain_node": fallback["chain_node"],
|
||||||
|
"stock_role": fallback.get("stock_role", "待归类"),
|
||||||
|
"score": getattr(rec, "score", 0),
|
||||||
|
"action_plan": getattr(rec, "action_plan", "观察"),
|
||||||
|
"trigger": getattr(rec, "trigger_condition", "") or getattr(rec, "entry_timing", ""),
|
||||||
|
"invalid_condition": getattr(rec, "invalidation_condition", "") or getattr(rec, "risk_note", ""),
|
||||||
|
"decision_trace": getattr(rec, "decision_trace", {}) or {},
|
||||||
|
"reasons": getattr(rec, "reasons", []) or [],
|
||||||
|
},
|
||||||
|
"theme_view": theme_view,
|
||||||
|
"recent_catalysts": catalysts,
|
||||||
|
"risk_alerts": risks,
|
||||||
|
}
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"你是A股研究员。你的任务是把系统筛出的候选标的整理成研究笔记,"
|
||||||
|
"用于解释机会逻辑、证据、分歧点和失效条件。不要给无条件买入建议,"
|
||||||
|
"不要编造未提供的数据。只输出合法JSON。"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
"请基于以下结构化输入生成研究笔记。输出JSON字段必须为:"
|
||||||
|
"logic_score(0-100数字), logic_summary(80字内), evidence(字符串数组,最多5条), "
|
||||||
|
"disagreement(80字内), uncertainty(80字内), invalid_condition(80字内)。\n\n"
|
||||||
|
f"{json.dumps(payload, ensure_ascii=False, default=str)}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
message = await chat_completion(messages)
|
||||||
|
content = _message_content(message)
|
||||||
|
parsed = _extract_json_object(content)
|
||||||
|
if not parsed:
|
||||||
|
return None
|
||||||
|
|
||||||
|
evidence = parsed.get("evidence") if isinstance(parsed.get("evidence"), list) else fallback["evidence"]
|
||||||
|
note = dict(fallback)
|
||||||
|
note.update({
|
||||||
|
"logic_score": _clamp_float(parsed.get("logic_score"), fallback["logic_score"], 0, 100),
|
||||||
|
"logic_summary": _clean_text(parsed.get("logic_summary"), fallback["logic_summary"], 120),
|
||||||
|
"evidence": [_clean_text(item, "", 80) for item in evidence if _clean_text(item, "", 80)][:5],
|
||||||
|
"disagreement": _clean_text(parsed.get("disagreement"), fallback["disagreement"], 120),
|
||||||
|
"uncertainty": _clean_text(parsed.get("uncertainty"), fallback["uncertainty"], 120),
|
||||||
|
"invalid_condition": _clean_text(parsed.get("invalid_condition"), fallback["invalid_condition"], 120),
|
||||||
|
"generated_by": "llm",
|
||||||
|
})
|
||||||
|
if not note["evidence"]:
|
||||||
|
note["evidence"] = fallback["evidence"]
|
||||||
|
return note
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_local_catalyst_context(rec: Any, theme: str) -> list[dict]:
|
||||||
|
name = str(getattr(rec, "name", "") or "")
|
||||||
|
ts_code = str(getattr(rec, "ts_code", "") or "")
|
||||||
|
if not theme and not name and not ts_code:
|
||||||
|
return []
|
||||||
|
|
||||||
|
limit = max(1, int(settings.research_stock_news_limit or 6))
|
||||||
|
params = {
|
||||||
|
"theme": theme,
|
||||||
|
"name": f"%{name}%" if name else "%",
|
||||||
|
"ts_code": f"%{ts_code}%" if ts_code else "%",
|
||||||
|
"limit": limit,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT c.title, c.summary, c.source, c.published_at, c.catalyst_type, "
|
||||||
|
"c.strength, c.confidence, tc.theme_name, tc.reason "
|
||||||
|
"FROM catalysts c "
|
||||||
|
"LEFT JOIN theme_catalysts tc ON tc.catalyst_id = c.id "
|
||||||
|
"WHERE c.is_active = 1 AND ("
|
||||||
|
"tc.theme_name = :theme OR c.title LIKE :name OR c.summary LIKE :name OR c.raw_text LIKE :ts_code"
|
||||||
|
") "
|
||||||
|
"ORDER BY COALESCE(c.published_at, c.created_at) DESC, c.id DESC "
|
||||||
|
"LIMIT :limit"
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
rows = []
|
||||||
|
for row in result.fetchall():
|
||||||
|
item = dict(row._mapping)
|
||||||
|
rows.append({
|
||||||
|
"title": item.get("title", ""),
|
||||||
|
"summary": item.get("summary", ""),
|
||||||
|
"source": item.get("source", ""),
|
||||||
|
"published_at": str(item.get("published_at") or ""),
|
||||||
|
"theme": item.get("theme_name", ""),
|
||||||
|
"reason": item.get("reason", ""),
|
||||||
|
"strength": item.get("strength", 0),
|
||||||
|
"confidence": item.get("confidence", 0),
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("读取本地催化上下文失败 ts_code=%s error=%s", ts_code, exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _group_risks(risks: list[dict]) -> dict[str, list[dict]]:
|
||||||
|
grouped: dict[str, list[dict]] = {}
|
||||||
|
for risk in risks:
|
||||||
|
ts_code = str(risk.get("ts_code") or "")
|
||||||
|
if ts_code:
|
||||||
|
grouped.setdefault(ts_code, []).append(risk)
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
def _match_theme_view(rec: Any, theme_views: list[dict]) -> dict:
|
||||||
|
sector = str(getattr(rec, "sector", "") or "")
|
||||||
|
for item in theme_views:
|
||||||
|
theme = str(item.get("theme") or "")
|
||||||
|
raw_sector = str(item.get("raw_sector") or "")
|
||||||
|
if sector and (sector == theme or sector == raw_sector or theme in sector or sector in raw_sector):
|
||||||
|
return item
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _message_content(message: Any) -> str:
|
||||||
|
if not message:
|
||||||
|
return ""
|
||||||
|
if isinstance(message, dict):
|
||||||
|
return str(message.get("content") or "")
|
||||||
|
return str(getattr(message, "content", "") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_object(content: str) -> dict:
|
||||||
|
if not content:
|
||||||
|
return {}
|
||||||
|
cleaned = content.strip()
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
cleaned = re.sub(r"^```(?:json)?", "", cleaned, flags=re.IGNORECASE).strip()
|
||||||
|
cleaned = re.sub(r"```$", "", cleaned).strip()
|
||||||
|
try:
|
||||||
|
parsed = json.loads(cleaned)
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
match = re.search(r"\{.*\}", cleaned, flags=re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = json.loads(match.group(0))
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_text(value: Any, fallback: str, max_chars: int) -> str:
|
||||||
|
text_value = str(value or "").strip()
|
||||||
|
if not text_value:
|
||||||
|
text_value = fallback
|
||||||
|
return text_value[:max_chars]
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_float(value: Any, fallback: float, minimum: float, maximum: float) -> float:
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except Exception:
|
||||||
|
number = float(fallback or 0)
|
||||||
|
return round(max(minimum, min(maximum, number)), 1)
|
||||||
Binary file not shown.
@ -85,6 +85,14 @@ export default function AdminPage() {
|
|||||||
secondary={`${stats.remainingInvites} 个剩余名额`}
|
secondary={`${stats.remainingInvites} 个剩余名额`}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
|
<AdminEntryCard
|
||||||
|
href="/admin/themes"
|
||||||
|
title="主题知识库"
|
||||||
|
description="维护主题别名、产业链环节和研究逻辑。扫描会优先使用这里的图谱。"
|
||||||
|
primary="产业链映射"
|
||||||
|
secondary="AI 研究输入"
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
316
frontend/src/app/(auth)/admin/themes/page.tsx
Normal file
316
frontend/src/app/(auth)/admin/themes/page.tsx
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { listThemeKnowledgeAPI, updateThemeKnowledgeAPI, type ThemeChainItem, type ThemeKnowledgeItem } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
|
||||||
|
const STAGES = [
|
||||||
|
{ value: "early", label: "启动期" },
|
||||||
|
{ value: "mid", label: "扩散期" },
|
||||||
|
{ value: "late", label: "后段" },
|
||||||
|
{ value: "end", label: "退潮" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminThemesPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [items, setItems] = useState<ThemeKnowledgeItem[]>([]);
|
||||||
|
const [selected, setSelected] = useState("");
|
||||||
|
const [draft, setDraft] = useState<ThemeKnowledgeItem | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (user?.role !== "admin") return;
|
||||||
|
setLoading(true);
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const rows = await listThemeKnowledgeAPI();
|
||||||
|
setItems(rows);
|
||||||
|
const first = rows[0]?.theme || "";
|
||||||
|
setSelected(first);
|
||||||
|
setDraft(rows.find((item) => item.theme === first) ?? rows[0] ?? null);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof Error ? error.message : "主题知识库加载失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [user?.role]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const nodes = items.reduce((sum, item) => sum + item.chain_nodes.length, 0);
|
||||||
|
const aliases = items.reduce((sum, item) => sum + item.aliases.length, 0);
|
||||||
|
return { themes: items.length, nodes, aliases };
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const selectTheme = (theme: string) => {
|
||||||
|
setSelected(theme);
|
||||||
|
setDraft(items.find((item) => item.theme === theme) ?? null);
|
||||||
|
setMessage("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveDraft = async () => {
|
||||||
|
if (!draft) return;
|
||||||
|
setMessage("");
|
||||||
|
const saved = await updateThemeKnowledgeAPI(selected || draft.theme, draft);
|
||||||
|
setMessage(`${saved.theme} 已保存`);
|
||||||
|
await loadData();
|
||||||
|
setSelected(saved.theme);
|
||||||
|
setDraft(saved);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (user?.role !== "admin") {
|
||||||
|
return <NoAccess />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl space-y-5 px-4 pb-20 pt-6 md:px-8 md:pb-10">
|
||||||
|
<Link href="/admin" className="inline-flex items-center gap-1.5 text-xs text-text-muted transition-colors hover:text-text-primary">
|
||||||
|
← 返回系统管理
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<header className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">Theme Graph</div>
|
||||||
|
<h1 className="mt-2 text-2xl font-bold tracking-tight text-text-primary">主题知识库</h1>
|
||||||
|
<p className="mt-1 text-sm text-text-muted">维护主题、别名和产业链节点,作为研究报告和机会卡的基础语义层。</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadData}
|
||||||
|
className="rounded-xl border border-border-subtle bg-surface-2 px-4 py-2 text-xs text-text-secondary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
刷新知识库
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{message ? <div className="glass-card-static border-amber-500/15 px-4 py-2.5 text-xs text-amber-400">{message}</div> : null}
|
||||||
|
|
||||||
|
<section className="grid grid-cols-3 gap-3">
|
||||||
|
<StatCard label="主题" value={loading ? "-" : stats.themes} />
|
||||||
|
<StatCard label="链路节点" value={loading ? "-" : stats.nodes} />
|
||||||
|
<StatCard label="别名" value={loading ? "-" : stats.aliases} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
|
<div className="glass-card-static overflow-hidden">
|
||||||
|
<div className="border-b border-border-subtle px-4 py-3 text-sm font-semibold text-text-primary">主题列表</div>
|
||||||
|
<div className="max-h-[620px] overflow-y-auto p-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.theme}
|
||||||
|
onClick={() => selectTheme(item.theme)}
|
||||||
|
className={`mb-2 w-full rounded-xl px-3 py-3 text-left transition-colors ${selected === item.theme ? "bg-amber-500/[0.10] text-amber-300" : "bg-surface-2/60 text-text-secondary hover:bg-surface-2"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="truncate text-sm font-semibold">{item.theme}</span>
|
||||||
|
<span className="font-mono text-xs">{item.chain_nodes.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate text-[11px] text-text-muted">{item.aliases.slice(0, 4).join(" / ") || "暂无别名"}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!items.length && !loading ? <div className="p-4 text-sm text-text-muted">暂无主题。</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ThemeEditor draft={draft} setDraft={setDraft} onSave={saveDraft} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeEditor({
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
draft: ThemeKnowledgeItem | null;
|
||||||
|
setDraft: (item: ThemeKnowledgeItem) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}) {
|
||||||
|
if (!draft) {
|
||||||
|
return <div className="glass-card-static p-6 text-sm text-text-muted">选择一个主题后编辑。</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card-static p-5">
|
||||||
|
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_180px]">
|
||||||
|
<Field label="主题名称">
|
||||||
|
<input
|
||||||
|
value={draft.theme}
|
||||||
|
onChange={(event) => setDraft({ ...draft, theme: event.target.value })}
|
||||||
|
className="w-full rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-sm text-text-primary outline-none focus:border-amber-500/40"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="生命周期">
|
||||||
|
<select
|
||||||
|
value={draft.stage}
|
||||||
|
onChange={(event) => {
|
||||||
|
const label = STAGES.find((item) => item.value === event.target.value)?.label ?? "观察期";
|
||||||
|
setDraft({ ...draft, stage: event.target.value, lifecycle_status: label });
|
||||||
|
}}
|
||||||
|
className="w-full rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-sm text-text-primary outline-none focus:border-amber-500/40"
|
||||||
|
>
|
||||||
|
{STAGES.map((stage) => <option key={stage.value} value={stage.value}>{stage.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||||
|
<Field label="别名">
|
||||||
|
<textarea
|
||||||
|
value={draft.aliases.join("\n")}
|
||||||
|
onChange={(event) => setDraft({ ...draft, aliases: lines(event.target.value) })}
|
||||||
|
rows={7}
|
||||||
|
className="w-full resize-none rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 font-mono text-xs leading-5 text-text-primary outline-none focus:border-amber-500/40"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="节点清单">
|
||||||
|
<textarea
|
||||||
|
value={draft.chain_nodes.join("\n")}
|
||||||
|
onChange={(event) => setDraft(syncNodes(draft, lines(event.target.value)))}
|
||||||
|
rows={7}
|
||||||
|
className="w-full resize-none rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 font-mono text-xs leading-5 text-text-primary outline-none focus:border-amber-500/40"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="mb-2 text-[11px] font-semibold text-text-secondary">节点股票映射</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{ensureChainItems(draft).map((item, index) => (
|
||||||
|
<NodeMappingEditor
|
||||||
|
key={`${item.chain_node}-${index}`}
|
||||||
|
item={item}
|
||||||
|
onChange={(next) => setDraft(updateChainItem(draft, index, next))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="研究逻辑" className="mt-4">
|
||||||
|
<textarea
|
||||||
|
value={draft.logic}
|
||||||
|
onChange={(event) => setDraft({ ...draft, logic: event.target.value })}
|
||||||
|
rows={5}
|
||||||
|
className="w-full resize-none rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-sm leading-6 text-text-primary outline-none focus:border-amber-500/40"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="mt-5 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
className="rounded-xl border border-amber-500/20 bg-amber-500/[0.10] px-5 py-2 text-sm font-semibold text-amber-300 hover:bg-amber-500/[0.14]"
|
||||||
|
>
|
||||||
|
保存主题
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeMappingEditor({ item, onChange }: { item: ThemeChainItem; onChange: (item: ThemeChainItem) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border-subtle bg-surface-2/45 p-3">
|
||||||
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_160px]">
|
||||||
|
<Field label="环节">
|
||||||
|
<input
|
||||||
|
value={item.chain_node}
|
||||||
|
onChange={(event) => onChange({ ...item, chain_node: event.target.value })}
|
||||||
|
className="w-full rounded-xl border border-border-subtle bg-surface-1 px-3 py-2 text-sm text-text-primary outline-none focus:border-amber-500/40"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="角色">
|
||||||
|
<input
|
||||||
|
value={item.node_role}
|
||||||
|
onChange={(event) => onChange({ ...item, node_role: event.target.value })}
|
||||||
|
placeholder="核心零部件"
|
||||||
|
className="w-full rounded-xl border border-border-subtle bg-surface-1 px-3 py-2 text-sm text-text-primary outline-none focus:border-amber-500/40"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||||
|
<Field label="核心股">
|
||||||
|
<textarea
|
||||||
|
value={stockText(item.leader_stocks)}
|
||||||
|
onChange={(event) => onChange({ ...item, leader_stocks: stockLines(event.target.value) })}
|
||||||
|
rows={3}
|
||||||
|
placeholder={"中际旭创\n新易盛"}
|
||||||
|
className="w-full resize-none rounded-xl border border-border-subtle bg-surface-1 px-3 py-2 font-mono text-xs leading-5 text-text-primary outline-none focus:border-amber-500/40"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="相关股">
|
||||||
|
<textarea
|
||||||
|
value={stockText(item.related_stocks)}
|
||||||
|
onChange={(event) => onChange({ ...item, related_stocks: stockLines(event.target.value) })}
|
||||||
|
rows={3}
|
||||||
|
placeholder="股票名或 ts_code,每行一个"
|
||||||
|
className="w-full resize-none rounded-xl border border-border-subtle bg-surface-1 px-3 py-2 font-mono text-xs leading-5 text-text-primary outline-none focus:border-amber-500/40"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children, className = "" }: { label: string; children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<label className={`block ${className}`}>
|
||||||
|
<div className="mb-2 text-[11px] font-semibold text-text-secondary">{label}</div>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div className="glass-card-static p-4">
|
||||||
|
<div className="text-[10px] text-text-muted">{label}</div>
|
||||||
|
<div className="mt-2 font-mono text-2xl font-bold tabular-nums text-text-primary">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoAccess() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl px-4 pb-20 pt-8 md:px-8">
|
||||||
|
<div className="glass-card-static p-6 text-sm text-text-secondary">当前账号没有主题知识库权限。</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lines(value: string): string[] {
|
||||||
|
return value.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureChainItems(item: ThemeKnowledgeItem): ThemeChainItem[] {
|
||||||
|
const existing = item.chain_items ?? [];
|
||||||
|
if (existing.length) return existing;
|
||||||
|
return item.chain_nodes.map((node) => ({ chain_node: node, related_stocks: [], leader_stocks: [], node_role: "" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncNodes(item: ThemeKnowledgeItem, nodes: string[]): ThemeKnowledgeItem {
|
||||||
|
const existing = ensureChainItems(item);
|
||||||
|
const nextItems = nodes.map((node) => {
|
||||||
|
const found = existing.find((chainItem) => chainItem.chain_node === node);
|
||||||
|
return found ?? { chain_node: node, related_stocks: [], leader_stocks: [], node_role: "" };
|
||||||
|
});
|
||||||
|
return { ...item, chain_nodes: nodes, chain_items: nextItems };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChainItem(item: ThemeKnowledgeItem, index: number, next: ThemeChainItem): ThemeKnowledgeItem {
|
||||||
|
const chainItems = ensureChainItems(item).map((chainItem, itemIndex) => (itemIndex === index ? next : chainItem));
|
||||||
|
return { ...item, chain_items: chainItems, chain_nodes: chainItems.map((chainItem) => chainItem.chain_node).filter(Boolean) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function stockText(values: ThemeChainItem["leader_stocks"]): string {
|
||||||
|
return values.map((item) => (typeof item === "string" ? item : item.name || item.ts_code || "")).filter(Boolean).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stockLines(value: string): string[] {
|
||||||
|
return value.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
192
frontend/src/app/(auth)/agents/page.tsx
Normal file
192
frontend/src/app/(auth)/agents/page.tsx
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { fetchAPI, getScanLogsAPI, type ResearchReport, type ScanProcessLog } from "@/lib/api";
|
||||||
|
|
||||||
|
const AGENT_LABELS: Record<string, { title: string; plain: string }> = {
|
||||||
|
market: { title: "看市场", plain: "判断今天适不适合出手。" },
|
||||||
|
sector: { title: "找主线", plain: "找出当前资金更关注的方向。" },
|
||||||
|
catalyst: { title: "看消息", plain: "把新闻、政策和公告归到相关主题。" },
|
||||||
|
stock: { title: "挑标的", plain: "从候选里整理机会和理由。" },
|
||||||
|
risk: { title: "查风险", plain: "把明显不适合参与的风险拦下来。" },
|
||||||
|
ranking: { title: "排优先级", plain: "结合复盘结果调整机会顺序。" },
|
||||||
|
review: { title: "做复盘", plain: "看最近哪些逻辑有效,哪些失效。" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AgentsPage() {
|
||||||
|
const [report, setReport] = useState<ResearchReport | null>(null);
|
||||||
|
const [logs, setLogs] = useState<ScanProcessLog[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [research, scanLogs] = await Promise.all([
|
||||||
|
fetchAPI<ResearchReport>("/api/research/today").catch(() => null),
|
||||||
|
getScanLogsAPI(undefined, 7, 80).catch(() => ({ scan_session: null, logs: [] })),
|
||||||
|
]);
|
||||||
|
setReport(research);
|
||||||
|
setLogs(scanLogs.logs ?? []);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const cards = useMemo(() => buildAgentCards(report, logs), [report, logs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl space-y-5 px-4 pb-20 pt-6 md:px-8 md:pb-10">
|
||||||
|
<header className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold tracking-tight text-text-primary">工作记录</h1>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">这里记录系统今天看了什么、拦了什么、留下了什么。</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={loadData} className="rounded-xl border border-border-subtle bg-surface-2 px-4 py-2 text-xs text-text-secondary hover:text-text-primary">
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-48 animate-shimmer rounded-2xl bg-surface-2" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<AgentCard key={card.key} card={card} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="glass-card-static overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">最近一次扫描</h2>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">按实际执行顺序排列。</p>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-xs text-text-muted">{logs.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border-subtle">
|
||||||
|
{logs.length ? logs.map((log) => <ScanStep key={log.id} log={log} />) : (
|
||||||
|
<div className="p-6 text-sm text-text-muted">暂无扫描记录。</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentCard({ card }: { card: ReturnType<typeof buildAgentCards>[number] }) {
|
||||||
|
return (
|
||||||
|
<article className="glass-card-static p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-400">{card.title}</div>
|
||||||
|
<h2 className="mt-2 text-base font-bold text-text-primary">{card.headline}</h2>
|
||||||
|
</div>
|
||||||
|
<span className={`rounded-lg px-2 py-1 text-[10px] ${card.tone}`}>{card.status}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-text-secondary">{card.detail}</p>
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||||
|
{card.facts.map((fact) => (
|
||||||
|
<div key={fact.label} className="rounded-xl bg-surface-2/55 px-3 py-2">
|
||||||
|
<div className="text-[10px] text-text-muted">{fact.label}</div>
|
||||||
|
<div className="mt-1 truncate font-mono text-sm font-semibold text-text-primary">{fact.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScanStep({ log }: { log: ScanProcessLog }) {
|
||||||
|
return (
|
||||||
|
<article className="grid gap-3 px-4 py-3 md:grid-cols-[160px_minmax(0,1fr)_120px] md:items-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-text-primary">{plainStage(log.stage, log.stage_label)}</div>
|
||||||
|
<div className="mt-1 text-[11px] text-text-muted">{formatTime(log.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm leading-6 text-text-secondary">{log.summary || "完成"}</div>
|
||||||
|
<div className="grid grid-cols-3 gap-1 text-center font-mono text-[11px] text-text-muted">
|
||||||
|
<span>{log.input_count}</span>
|
||||||
|
<span>{log.output_count}</span>
|
||||||
|
<span>{log.filtered_count}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAgentCards(report: ResearchReport | null, logs: ScanProcessLog[]) {
|
||||||
|
const riskRejects = report?.risk_alerts?.filter((item) => item.reject).length ?? 0;
|
||||||
|
const riskWarnings = report?.risk_alerts?.filter((item) => !item.reject).length ?? 0;
|
||||||
|
return [
|
||||||
|
card("market", report?.market_view.summary || "等待市场判断", [
|
||||||
|
["状态", report ? formatRegime(report.market_view.regime) : "-"],
|
||||||
|
["置信", report ? `${Math.round((report.market_view.confidence ?? 0) * 100)}%` : "-"],
|
||||||
|
]),
|
||||||
|
card("sector", report?.summary?.theme || "等待主线结论", [
|
||||||
|
["主题", String(report?.theme_views?.length ?? 0)],
|
||||||
|
["强主题", report?.theme_views?.[0]?.theme || "-"],
|
||||||
|
]),
|
||||||
|
card("catalyst", report?.catalyst?.summary || "等待消息归因", [
|
||||||
|
["主题线索", String(report?.catalyst?.strong_theme_count ?? 0)],
|
||||||
|
["来源", "新闻/公告"],
|
||||||
|
]),
|
||||||
|
card("stock", report?.opportunity_cards?.length ? `留下 ${report.opportunity_cards.length} 张机会卡` : report?.no_trade_reason?.reason || "暂无机会", [
|
||||||
|
["机会", String(report?.opportunity_cards?.length ?? 0)],
|
||||||
|
["最近", report ? formatTime(report.scanned_at) : "-"],
|
||||||
|
]),
|
||||||
|
card("risk", riskRejects ? `拦下 ${riskRejects} 个高风险点` : "没有发现一票否决风险", [
|
||||||
|
["否决", String(riskRejects)],
|
||||||
|
["预警", String(riskWarnings)],
|
||||||
|
], riskRejects ? "有风险" : "正常"),
|
||||||
|
card("ranking", report?.ranking_feedback?.summary?.headline || "等待复盘调权", [
|
||||||
|
["样本", String(report?.ranking_feedback?.tracked_count ?? 0)],
|
||||||
|
["窗口", `${report?.ranking_feedback?.days ?? 0}天`],
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
function card(key: string, headline: string, facts: Array<[string, string]>, status = "已完成") {
|
||||||
|
const meta = AGENT_LABELS[key];
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
title: meta.title,
|
||||||
|
headline,
|
||||||
|
detail: meta.plain,
|
||||||
|
status,
|
||||||
|
tone: status === "有风险" ? "bg-red-500/10 text-red-300" : "bg-emerald-500/10 text-emerald-300",
|
||||||
|
facts: facts.map(([label, value]) => ({ label, value })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainStage(stage: string, fallback: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
universe_filter: "先剔除不适合看的股票",
|
||||||
|
final_filter: "最后留下可跟踪对象",
|
||||||
|
realtime: "刷新盘中数据",
|
||||||
|
sector: "更新主线方向",
|
||||||
|
};
|
||||||
|
return labels[stage] ?? fallback ?? stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRegime(value?: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
bullish_rotation: "可以找机会",
|
||||||
|
defensive_watch: "谨慎观察",
|
||||||
|
risk_off: "先防守",
|
||||||
|
unknown: "等待判断",
|
||||||
|
};
|
||||||
|
return labels[value ?? ""] ?? value ?? "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value?: string) {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value.includes("T") ? value : value.replace(" ", "T"));
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import type {
|
|||||||
MarketTemperatureData,
|
MarketTemperatureData,
|
||||||
OpsStatusResponse,
|
OpsStatusResponse,
|
||||||
RecommendationData,
|
RecommendationData,
|
||||||
|
ResearchReport,
|
||||||
SectorData,
|
SectorData,
|
||||||
StrategyBoard,
|
StrategyBoard,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
@ -35,6 +36,7 @@ export default function DashboardPage() {
|
|||||||
const [indices, setIndices] = useState<IndexOverview[]>([]);
|
const [indices, setIndices] = useState<IndexOverview[]>([]);
|
||||||
const [strategyBoard, setStrategyBoard] = useState<StrategyBoard | null>(null);
|
const [strategyBoard, setStrategyBoard] = useState<StrategyBoard | null>(null);
|
||||||
const [opsStatus, setOpsStatus] = useState<OpsStatusResponse | null>(null);
|
const [opsStatus, setOpsStatus] = useState<OpsStatusResponse | null>(null);
|
||||||
|
const [research, setResearch] = useState<ResearchReport | null>(null);
|
||||||
const [opsRunning, setOpsRunning] = useState<string | null>(null);
|
const [opsRunning, setOpsRunning] = useState<string | null>(null);
|
||||||
const scanTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const scanTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
@ -59,6 +61,8 @@ export default function DashboardPage() {
|
|||||||
setSectors(sectorData);
|
setSectors(sectorData);
|
||||||
if (status) setScanStatus(status);
|
if (status) setScanStatus(status);
|
||||||
setIndices(overview);
|
setIndices(overview);
|
||||||
|
const researchResult = await fetchAPI<ResearchReport>("/api/research/today").catch(() => null);
|
||||||
|
setResearch(researchResult);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("加载数据失败:", error);
|
console.error("加载数据失败:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -203,7 +207,7 @@ export default function DashboardPage() {
|
|||||||
<div className="flex items-start justify-between gap-4 animate-fade-in-up">
|
<div className="flex items-start justify-between gap-4 animate-fade-in-up">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-lg font-bold tracking-tight">今日作战</h1>
|
<h1 className="text-lg font-bold tracking-tight">今日研究台</h1>
|
||||||
{scanStatus?.is_trading ? (
|
{scanStatus?.is_trading ? (
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] text-emerald-400">
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] text-emerald-400">
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
@ -240,30 +244,21 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="animate-fade-in-up">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_320px] animate-fade-in-up">
|
||||||
<DecisionHero
|
<TodayResearchDesk
|
||||||
board={strategyBoard}
|
report={research}
|
||||||
summary={marketSummary}
|
summary={marketSummary}
|
||||||
actions={todayActions}
|
actions={todayActions}
|
||||||
marketTemperature={marketTemperature ?? data?.market_temperature ?? null}
|
|
||||||
sectors={sectors}
|
|
||||||
focusQueue={focusQueue.slice(0, 3)}
|
|
||||||
actionableCount={actionable.length}
|
|
||||||
watchCount={watch.length}
|
|
||||||
observeCount={observe.length}
|
|
||||||
latestScan={latestScan}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4 animate-fade-in-up">
|
|
||||||
<OpportunityBoard
|
|
||||||
sectors={sectors}
|
sectors={sectors}
|
||||||
focusQueue={focusQueue.slice(0, 6)}
|
focusQueue={focusQueue.slice(0, 6)}
|
||||||
actionableCount={actionable.length}
|
actionableCount={actionable.length}
|
||||||
watchCount={watch.length}
|
watchCount={watch.length}
|
||||||
|
observeCount={observe.length}
|
||||||
latestScan={latestScan}
|
latestScan={latestScan}
|
||||||
|
marketTemperature={marketTemperature ?? data?.market_temperature ?? null}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<AgentBrief report={research} latestScan={latestScan} />
|
||||||
<CompactMarketEvidence
|
<CompactMarketEvidence
|
||||||
marketTemperature={marketTemperature ?? data?.market_temperature ?? null}
|
marketTemperature={marketTemperature ?? data?.market_temperature ?? null}
|
||||||
indices={indices}
|
indices={indices}
|
||||||
@ -284,6 +279,432 @@ export default function DashboardPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TodayResearchDesk({
|
||||||
|
report,
|
||||||
|
summary,
|
||||||
|
actions,
|
||||||
|
sectors,
|
||||||
|
focusQueue,
|
||||||
|
actionableCount,
|
||||||
|
watchCount,
|
||||||
|
observeCount,
|
||||||
|
latestScan,
|
||||||
|
marketTemperature,
|
||||||
|
}: {
|
||||||
|
report: ResearchReport | null;
|
||||||
|
summary: ReturnType<typeof buildMarketSummary>;
|
||||||
|
actions: ReturnType<typeof buildActionGuides>;
|
||||||
|
sectors: SectorData[];
|
||||||
|
focusQueue: RecommendationData[];
|
||||||
|
actionableCount: number;
|
||||||
|
watchCount: number;
|
||||||
|
observeCount: number;
|
||||||
|
latestScan: LatestResult["latest_scan"];
|
||||||
|
marketTemperature: MarketTemperatureData | null;
|
||||||
|
}) {
|
||||||
|
const themes = report?.theme_views?.length ? report.theme_views : sectorsToThemeViews(sectors);
|
||||||
|
const opportunities = report?.opportunity_cards ?? [];
|
||||||
|
const risks = report?.risk_alerts ?? [];
|
||||||
|
const hasOpportunity = opportunities.length > 0 || focusQueue.length > 0;
|
||||||
|
const conclusion = report?.market_view.summary || summary.headline;
|
||||||
|
const subConclusion =
|
||||||
|
report?.summary?.theme ||
|
||||||
|
report?.catalyst?.summary ||
|
||||||
|
summary.detail ||
|
||||||
|
"系统会先判断市场,再看主线,最后筛机会和风险。";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card-static overflow-hidden">
|
||||||
|
<section className="border-b border-border-subtle p-5 md:p-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="rounded-full bg-amber-500/10 px-2.5 py-1 text-[11px] font-semibold text-amber-400">
|
||||||
|
{hasOpportunity ? "有机会,挑着做" : "没有合适买点"}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-surface-2 px-2.5 py-1 text-[11px] text-text-muted">
|
||||||
|
{latestScan?.created_at ? formatScanTime(latestScan.created_at) : "暂无扫描"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<CompactBadge label="市场" value={report ? formatRegime(report.market_view.regime) : summary.modeLabel} />
|
||||||
|
<CompactBadge label="仓位" value={summary.positionLabel} />
|
||||||
|
<CompactBadge label="温度" value={`${Math.round(report?.market_view.temperature ?? marketTemperature?.temperature ?? 0)}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px] lg:items-end">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight text-text-primary md:text-5xl">{conclusion}</h2>
|
||||||
|
<p className="mt-3 max-w-3xl text-sm leading-6 text-text-secondary">{subConclusion}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<HeroMetric label="可操作" value={actionableCount || opportunities.filter((item) => item.action_plan === "可操作").length} tone="text-red-400" />
|
||||||
|
<HeroMetric label="关注" value={watchCount} tone="text-amber-400" />
|
||||||
|
<HeroMetric label="观察" value={observeCount} tone="text-text-secondary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasOpportunity ? (
|
||||||
|
<div className="mt-5 rounded-2xl border border-amber-500/15 bg-amber-500/[0.05] p-4">
|
||||||
|
<div className="text-sm font-semibold text-amber-300">为什么今天不出手</div>
|
||||||
|
<div className="mt-2 text-sm leading-6 text-text-secondary">
|
||||||
|
{report?.no_trade_reason?.reason || latestScan?.summary || "本次扫描没有同时满足主线、买点和风险收益比。"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{report?.data_quality?.status === "degraded" ? (
|
||||||
|
<div className="mt-4 rounded-2xl border border-amber-500/15 bg-amber-500/[0.05] p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="text-sm font-semibold text-amber-300">数据有降级</div>
|
||||||
|
<span className="rounded-lg bg-surface-2/70 px-2 py-1 text-[10px] text-text-muted">
|
||||||
|
{report.data_quality.market_data_status || "degraded"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{(report.data_quality.warnings ?? []).slice(0, 2).map((warning) => (
|
||||||
|
<div key={warning} className="text-xs leading-5 text-text-secondary">{warning}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 p-5 md:p-6 xl:grid-cols-[minmax(280px,0.85fr)_minmax(0,1.15fr)]">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary">今天主线</h3>
|
||||||
|
<a href="/sectors" className="text-[11px] text-text-muted hover:text-text-secondary">主题图谱</a>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{themes.slice(0, 4).map((theme, index) => (
|
||||||
|
<PlainThemeCard key={`${theme.theme}-${index}`} theme={theme} rank={index + 1} />
|
||||||
|
))}
|
||||||
|
{!themes.length ? (
|
||||||
|
<div className="rounded-2xl bg-surface-2/45 p-6 text-sm text-text-muted">等待下一次扫描识别主线。</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary">今天看什么标的</h3>
|
||||||
|
<a href="/recommendations" className="text-[11px] text-text-muted hover:text-text-secondary">机会清单</a>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||||
|
{opportunities.length ? opportunities.slice(0, 6).map((item) => (
|
||||||
|
<ResearchOpportunityCardView key={item.ts_code} item={item} />
|
||||||
|
)) : focusQueue.length ? focusQueue.map((rec) => (
|
||||||
|
<LargeFocusStockCard key={rec.ts_code} rec={rec} />
|
||||||
|
)) : (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<EmptyScanResult latestScan={latestScan} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 border-t border-border-subtle bg-surface-1/35 p-5 md:p-6 lg:grid-cols-3">
|
||||||
|
<ActionBucket title="现在做" items={actions.priority.slice(0, 2)} tone="do" />
|
||||||
|
<ActionBucket title="等待确认" items={actions.watch.slice(0, 2)} tone="wait" />
|
||||||
|
<RiskBucket risks={risks} avoid={actions.avoid.slice(0, 2)} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentBrief({ report, latestScan }: { report: ResearchReport | null; latestScan: LatestResult["latest_scan"] }) {
|
||||||
|
const done = [
|
||||||
|
report?.market_view?.summary ? "看过市场" : "",
|
||||||
|
report?.theme_views?.length ? "找过主线" : "",
|
||||||
|
report?.opportunity_cards?.length ? "排过机会" : "",
|
||||||
|
report?.risk_alerts?.length ? "查过风险" : "",
|
||||||
|
].filter(Boolean);
|
||||||
|
return (
|
||||||
|
<div className="glass-card-static p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary">系统做了什么</h3>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">{latestScan?.created_at ? formatScanTime(latestScan.created_at) : "等待扫描"}</p>
|
||||||
|
</div>
|
||||||
|
<a href="/agents" className="rounded-xl bg-surface-2 px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary">详情</a>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{(done.length ? done : ["等待研究报告"]).map((item) => (
|
||||||
|
<span key={item} className="rounded-lg bg-surface-2/70 px-2 py-1 text-[11px] text-text-secondary">{item}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-xs leading-5 text-text-secondary">
|
||||||
|
{report?.no_trade_reason?.reason || report?.summary?.market || latestScan?.summary || "扫描完成后这里会显示本轮研究结论。"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlainThemeCard({ theme, rank }: { theme: ResearchThemeViewLike; rank: number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl bg-surface-2/40 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-lg bg-amber-500/10 font-mono text-[11px] text-amber-400">{rank}</span>
|
||||||
|
<div className="truncate text-sm font-semibold text-text-primary">{theme.theme}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs leading-5 text-text-secondary">{theme.logic || "关注板块强度、资金承接和前排表现。"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-sm font-semibold text-amber-400">{Math.round(theme.heat_score ?? 0)}</div>
|
||||||
|
</div>
|
||||||
|
{theme.chain_nodes?.length ? (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{theme.chain_nodes.slice(0, 4).map((node) => (
|
||||||
|
<span key={node} className="rounded-lg bg-bg-primary/40 px-2 py-1 text-[10px] text-text-muted">{node}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResearchOpportunityCardView({ item }: { item: ResearchOpportunityCard }) {
|
||||||
|
return (
|
||||||
|
<a href={`/stock/${item.ts_code}`} className="rounded-2xl bg-surface-2/40 p-4 transition-colors hover:bg-surface-2/65">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-text-primary">{item.name}</div>
|
||||||
|
<div className="mt-1 font-mono text-[11px] text-text-muted">{item.ts_code}</div>
|
||||||
|
</div>
|
||||||
|
<span className={`rounded-lg border px-2 py-1 text-[10px] ${alphaBadgeClass(item.risk_gate)}`}>
|
||||||
|
{item.alpha_type || item.action_plan || item.opportunity_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
<span className="rounded-lg bg-bg-primary/40 px-2 py-1 text-[10px] text-text-muted">{item.theme || "未归类"}</span>
|
||||||
|
{item.chain_node ? <span className="rounded-lg bg-bg-primary/40 px-2 py-1 text-[10px] text-text-muted">{item.chain_node}</span> : null}
|
||||||
|
<span className="rounded-lg bg-bg-primary/40 px-2 py-1 text-[10px] text-text-muted">Beta {item.beta_dependency || "中"}</span>
|
||||||
|
<span className="rounded-lg bg-bg-primary/40 px-2 py-1 text-[10px] text-text-muted">{item.risk_gate || "通过"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||||
|
<TinyInfo label="Alpha" value={Math.round(item.alpha_score ?? item.adjusted_score ?? item.score ?? 0)} />
|
||||||
|
<TinyInfo label="埋伏" value={Math.round(item.ambush_score ?? 0)} />
|
||||||
|
<TinyInfo label="预期差" value={Math.round(item.expectation_gap_score ?? 0)} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 line-clamp-2 text-xs leading-5 text-text-secondary">
|
||||||
|
{item.alpha_reason || item.logic_summary || item.trigger || "等待触发条件确认。"}
|
||||||
|
</div>
|
||||||
|
{item.invalid_condition ? (
|
||||||
|
<div className="mt-2 line-clamp-1 text-[11px] text-text-muted">失效:{item.invalid_condition}</div>
|
||||||
|
) : null}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function alphaBadgeClass(riskGate?: string) {
|
||||||
|
if (riskGate === "否决") return "border-red-500/15 bg-red-500/[0.08] text-red-300";
|
||||||
|
if (riskGate === "预警") return "border-amber-500/15 bg-amber-500/[0.08] text-amber-300";
|
||||||
|
return "border-emerald-500/15 bg-emerald-500/[0.08] text-emerald-300";
|
||||||
|
}
|
||||||
|
|
||||||
|
function RiskBucket({ risks, avoid }: { risks: ResearchRiskAlert[]; avoid: string[] }) {
|
||||||
|
const topRisks = risks.slice(0, 2);
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-red-500/10 bg-red-500/[0.04] p-3">
|
||||||
|
<div className="text-[11px] font-semibold text-red-300">先避开什么</div>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{topRisks.length ? topRisks.map((risk, index) => (
|
||||||
|
<div key={`${risk.ts_code}-${risk.risk_type}-${index}`} className="text-sm leading-6 text-text-secondary">
|
||||||
|
{risk.reject ? "否决" : "预警"}:{risk.reason}
|
||||||
|
</div>
|
||||||
|
)) : avoid.map((item, index) => (
|
||||||
|
<div key={`${item}-${index}`} className="text-sm leading-6 text-text-secondary">{item}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResearchThemeViewLike = Pick<ResearchReport["theme_views"][number], "theme" | "heat_score" | "logic" | "chain_nodes">;
|
||||||
|
|
||||||
|
type ResearchOpportunityCard = ResearchReport["opportunity_cards"][number];
|
||||||
|
|
||||||
|
type ResearchRiskAlert = ResearchReport["risk_alerts"][number];
|
||||||
|
|
||||||
|
function sectorsToThemeViews(sectors: SectorData[]): ResearchThemeViewLike[] {
|
||||||
|
return sectors.slice(0, 5).map((sector) => ({
|
||||||
|
theme: sector.sector_name,
|
||||||
|
heat_score: sector.heat_score ?? Math.abs(sector.realtime_pct_change ?? sector.pct_change ?? 0) * 10,
|
||||||
|
logic: `${sector.limit_up_count || 0} 只涨停,${sector.member_count || 0} 个成员,先看前排是否继续走强。`,
|
||||||
|
chain_nodes: (sector.leading_stocks_realtime ?? sector.leading_stocks ?? []).slice(0, 4).map((item) => item.name),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResearchConsole({ report }: { report: ResearchReport }) {
|
||||||
|
const themes = report.theme_views ?? [];
|
||||||
|
const opportunities = report.opportunity_cards ?? [];
|
||||||
|
const risks = report.risk_alerts ?? [];
|
||||||
|
const noTrade = report.no_trade_reason;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card-static overflow-hidden border-amber-500/10">
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.15fr)_360px]">
|
||||||
|
<div className="p-5 md:p-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">AlphaX Research</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<CompactBadge label="状态" value={formatRegime(report.market_view.regime)} />
|
||||||
|
<CompactBadge label="置信" value={`${Math.round((report.market_view.confidence ?? 0) * 100)}%`} />
|
||||||
|
<CompactBadge label="扫描" value={formatScanTime(report.scanned_at)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_170px]">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl md:text-4xl font-bold tracking-tight text-text-primary">
|
||||||
|
{report.market_view.summary || "等待研究报告生成"}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-text-secondary">
|
||||||
|
{report.summary?.theme || report.catalyst?.summary || "系统会在每次扫描后沉淀市场、主线、机会与风险。"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4 text-center">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-text-muted">机会卡</div>
|
||||||
|
<div className="mt-1 font-mono text-3xl font-bold tabular-nums text-amber-400">{opportunities.length}</div>
|
||||||
|
<div className="mt-1 text-[11px] text-text-muted">
|
||||||
|
否决 {report.risk_summary?.reject_count ?? risks.filter((item) => item.reject).length} / 预警 {report.risk_summary?.warning_count ?? risks.filter((item) => !item.reject).length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
|
{themes.slice(0, 3).map((theme) => (
|
||||||
|
<ThemeResearchCard key={`${theme.theme}-${theme.raw_sector}`} theme={theme} />
|
||||||
|
))}
|
||||||
|
{!themes.length ? (
|
||||||
|
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-5 text-sm text-text-muted md:col-span-3">
|
||||||
|
暂无主题图谱,等待下一次扫描。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border-subtle bg-surface-1/50 p-5 xl:border-l xl:border-t-0">
|
||||||
|
<div className="text-[11px] font-semibold text-text-secondary">机会与风险</div>
|
||||||
|
<div className="mt-3 space-y-2.5">
|
||||||
|
{opportunities.slice(0, 3).map((item) => (
|
||||||
|
<a key={item.ts_code} href={`/stock/${item.ts_code}`} className="block rounded-2xl bg-surface-2/45 p-3 transition-colors hover:bg-surface-2/70">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-text-primary">{item.name}</div>
|
||||||
|
<div className="mt-1 text-[11px] text-text-muted">
|
||||||
|
{item.theme} / {item.chain_node}{item.stock_role ? ` / ${item.stock_role}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-lg border border-amber-500/15 bg-amber-500/[0.06] px-2 py-1 text-[10px] text-amber-400">
|
||||||
|
{item.opportunity_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 line-clamp-2 text-xs leading-5 text-text-secondary">{item.logic_summary || item.trigger}</div>
|
||||||
|
{typeof item.review_adjustment === "number" && item.review_adjustment !== 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
<span className={`rounded-md px-1.5 py-0.5 font-mono text-[10px] ${item.review_adjustment > 0 ? "bg-red-500/10 text-red-300" : "bg-emerald-500/10 text-emerald-300"}`}>
|
||||||
|
复盘{item.review_adjustment > 0 ? "+" : ""}{item.review_adjustment}
|
||||||
|
</span>
|
||||||
|
{(item.review_feedback ?? []).slice(0, 2).map((feedback) => (
|
||||||
|
<span key={`${item.ts_code}-${feedback.source}-${feedback.label}`} className="rounded-md bg-surface-2/70 px-1.5 py-0.5 text-[10px] text-text-muted">
|
||||||
|
{feedback.source}:{feedback.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{item.invalid_condition ? (
|
||||||
|
<div className="mt-2 line-clamp-1 text-[11px] leading-5 text-text-muted">
|
||||||
|
失效:{item.invalid_condition}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
{!opportunities.length ? (
|
||||||
|
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
||||||
|
<div className="text-sm font-semibold text-text-primary">本轮无交易级机会</div>
|
||||||
|
<div className="mt-2 text-xs leading-5 text-text-secondary">
|
||||||
|
{noTrade?.reason || "扫描完成但没有形成机会卡。"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{risks.length ? (
|
||||||
|
<div className="mt-4 rounded-2xl border border-red-500/10 bg-red-500/[0.04] p-3">
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-red-300">Risk Agent · 一票否决</div>
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
{risks.slice(0, 3).map((risk, index) => (
|
||||||
|
<div key={`${risk.risk_type}-${risk.ts_code}-${index}`} className="rounded-lg bg-red-500/[0.04] px-2 py-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className={`rounded-md px-1.5 py-0.5 text-[10px] ${risk.reject ? "bg-red-500/15 text-red-200" : "bg-amber-500/10 text-amber-300"}`}>
|
||||||
|
{risk.reject ? "否决" : "预警"}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-md bg-surface-2/70 px-1.5 py-0.5 text-[10px] text-text-muted">
|
||||||
|
{formatRiskType(risk.risk_type)}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-md bg-surface-2/70 px-1.5 py-0.5 text-[10px] text-text-muted">
|
||||||
|
{risk.source}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 text-xs leading-5 text-red-200/80">{risk.reason}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRiskType(type: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
market_regime: "市场状态",
|
||||||
|
filter_pressure: "过滤压力",
|
||||||
|
position_risk: "买点位置",
|
||||||
|
unlock: "解禁",
|
||||||
|
holder_reduce: "减持",
|
||||||
|
earnings_forecast: "业绩预告",
|
||||||
|
pledge: "质押",
|
||||||
|
regulatory: "监管",
|
||||||
|
penalty: "处罚",
|
||||||
|
investigation: "立案调查",
|
||||||
|
litigation: "诉讼仲裁",
|
||||||
|
asset_freeze: "冻结违约",
|
||||||
|
accounting_risk: "会计风险",
|
||||||
|
audit_opinion: "审计意见",
|
||||||
|
goodwill: "商誉",
|
||||||
|
debt_pressure: "负债压力",
|
||||||
|
risk_data_source: "数据源",
|
||||||
|
};
|
||||||
|
return labels[type] ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeResearchCard({ theme }: { theme: ResearchReport["theme_views"][number] }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-text-primary">{theme.theme}</div>
|
||||||
|
<div className="mt-1 text-[11px] text-text-muted">{theme.lifecycle_status || theme.stage}</div>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-sm font-semibold tabular-nums text-amber-400">{Math.round(theme.heat_score)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 line-clamp-2 text-xs leading-5 text-text-secondary">{theme.logic}</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{theme.chain_nodes.slice(0, 4).map((node) => (
|
||||||
|
<span key={node} className="rounded-lg bg-surface-2/70 px-2 py-1 text-[10px] text-text-muted">{node}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DecisionHero({
|
function DecisionHero({
|
||||||
board,
|
board,
|
||||||
summary,
|
summary,
|
||||||
@ -808,6 +1229,18 @@ function formatScanTime(value: string) {
|
|||||||
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRegime(regime: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
bullish_mainline: "主线进攻",
|
||||||
|
bullish_rotation: "强势轮动",
|
||||||
|
range_rotation: "震荡轮动",
|
||||||
|
risk_off: "风险退潮",
|
||||||
|
defensive_watch: "防守观察",
|
||||||
|
unknown: "等待扫描",
|
||||||
|
};
|
||||||
|
return labels[regime] ?? regime;
|
||||||
|
}
|
||||||
|
|
||||||
function TinyInfo({ label, value }: { label: string; value: string | number }) {
|
function TinyInfo({ label, value }: { label: string; value: string | number }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-bg-primary/45 px-2 py-1.5">
|
<div className="rounded-lg bg-bg-primary/45 px-2 py-1.5">
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import type {
|
|||||||
OpsStatusResponse,
|
OpsStatusResponse,
|
||||||
PerformanceStats,
|
PerformanceStats,
|
||||||
RecommendationData,
|
RecommendationData,
|
||||||
|
ResearchReport,
|
||||||
|
ResearchReviewStats,
|
||||||
TrackedRecommendation,
|
TrackedRecommendation,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import StockCard from "@/components/stock-card";
|
import StockCard from "@/components/stock-card";
|
||||||
@ -59,21 +61,27 @@ export default function RecommendationsPage() {
|
|||||||
const [focusTab, setFocusTab] = useState<FocusTab>("actionable");
|
const [focusTab, setFocusTab] = useState<FocusTab>("actionable");
|
||||||
const [opsStatus, setOpsStatus] = useState<OpsStatusResponse | null>(null);
|
const [opsStatus, setOpsStatus] = useState<OpsStatusResponse | null>(null);
|
||||||
const [performance, setPerformance] = useState<PerformanceStats | null>(null);
|
const [performance, setPerformance] = useState<PerformanceStats | null>(null);
|
||||||
|
const [research, setResearch] = useState<ResearchReport | null>(null);
|
||||||
|
const [researchReview, setResearchReview] = useState<ResearchReviewStats | null>(null);
|
||||||
const [trackingUpdating, setTrackingUpdating] = useState(false);
|
const [trackingUpdating, setTrackingUpdating] = useState(false);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [history, latestResult, ops, perf] = await Promise.all([
|
const [history, latestResult, ops, perf, researchResult, reviewResult] = await Promise.all([
|
||||||
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
|
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
|
||||||
fetchAPI<LatestResult>("/api/recommendations/latest").catch(() => null),
|
fetchAPI<LatestResult>("/api/recommendations/latest").catch(() => null),
|
||||||
user?.role === "admin" ? fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null) : Promise.resolve(null),
|
user?.role === "admin" ? fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null) : Promise.resolve(null),
|
||||||
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
|
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
|
||||||
|
fetchAPI<ResearchReport>("/api/research/today").catch(() => null),
|
||||||
|
fetchAPI<ResearchReviewStats>("/api/research/review?days=60").catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setDayGroups(history);
|
setDayGroups(history);
|
||||||
setLatest(latestResult);
|
setLatest(latestResult);
|
||||||
setOpsStatus(ops);
|
setOpsStatus(ops);
|
||||||
setPerformance(perf);
|
setPerformance(perf);
|
||||||
|
setResearch(researchResult);
|
||||||
|
setResearchReview(reviewResult);
|
||||||
|
|
||||||
setExpandedDays((prev) => {
|
setExpandedDays((prev) => {
|
||||||
if (prev.size || history.length === 0) return prev;
|
if (prev.size || history.length === 0) return prev;
|
||||||
@ -174,9 +182,32 @@ export default function RecommendationsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||||
<div className="animate-fade-in-up">
|
<div className="animate-fade-in-up">
|
||||||
<h1 className="text-base sm:text-lg font-bold tracking-tight">今日决策池</h1>
|
<h1 className="text-base sm:text-lg font-bold tracking-tight">机会池</h1>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">从研究报告中拆分可操作、等确认、等回踩和仅观察机会。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{research ? (
|
||||||
|
<div className="glass-card-static p-4 animate-fade-in-up">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">Research Context</div>
|
||||||
|
<div className="mt-1 text-sm font-semibold text-text-primary">{research.market_view.summary}</div>
|
||||||
|
<div className="mt-1 text-xs text-text-muted">{research.summary?.theme || research.no_trade_reason?.reason}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<SummaryChip label="机会卡" value={`${research.opportunity_cards.length}`} />
|
||||||
|
<SummaryChip label="风险" value={`${research.risk_alerts.length}`} />
|
||||||
|
<SummaryChip label="调权样本" value={`${research.ranking_feedback?.tracked_count ?? 0}`} />
|
||||||
|
<SummaryChip label="扫描" value={formatScanTime(research.scanned_at)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{research?.opportunity_cards?.length ? (
|
||||||
|
<AlphaOpportunityStrip cards={research.opportunity_cards.slice(0, 6)} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="glass-card-static overflow-hidden animate-fade-in-up">
|
<div className="glass-card-static overflow-hidden animate-fade-in-up">
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px]">
|
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px]">
|
||||||
<div className="p-4 md:p-5">
|
<div className="p-4 md:p-5">
|
||||||
@ -247,6 +278,7 @@ export default function RecommendationsPage() {
|
|||||||
|
|
||||||
<PerformanceReviewCard
|
<PerformanceReviewCard
|
||||||
performance={performance}
|
performance={performance}
|
||||||
|
researchReview={researchReview}
|
||||||
canUpdate={user?.role === "admin"}
|
canUpdate={user?.role === "admin"}
|
||||||
updating={trackingUpdating}
|
updating={trackingUpdating}
|
||||||
onUpdate={updateTracking}
|
onUpdate={updateTracking}
|
||||||
@ -457,13 +489,73 @@ function buildFocusSummary({
|
|||||||
return { headline, detail, now, later };
|
return { headline, detail, now, later };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AlphaOpportunityStrip({ cards }: { cards: ResearchReport["opportunity_cards"] }) {
|
||||||
|
return (
|
||||||
|
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">Alpha 候选</div>
|
||||||
|
<h2 className="mt-1 text-base font-bold tracking-tight text-text-primary">更像独立机会的标的</h2>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-muted">优先看低 Beta 依赖、可埋伏、风险通过的机会。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<a key={card.ts_code} href={`/stock/${card.ts_code}`} className="rounded-2xl bg-surface-2/40 p-4 transition-colors hover:bg-surface-2/65">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-text-primary">{card.name}</div>
|
||||||
|
<div className="mt-1 font-mono text-[11px] text-text-muted">{card.ts_code}</div>
|
||||||
|
</div>
|
||||||
|
<span className={`rounded-lg border px-2 py-1 text-[10px] ${alphaRiskClass(card.risk_gate)}`}>
|
||||||
|
{card.alpha_type || card.opportunity_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||||
|
<AlphaMiniStat label="Alpha" value={card.alpha_score ?? card.score} />
|
||||||
|
<AlphaMiniStat label="埋伏" value={card.ambush_score ?? 0} />
|
||||||
|
<AlphaMiniStat label="预期差" value={card.expectation_gap_score ?? 0} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
<span className="rounded-lg bg-bg-primary/40 px-2 py-1 text-[10px] text-text-muted">Beta {card.beta_dependency || "中"}</span>
|
||||||
|
<span className="rounded-lg bg-bg-primary/40 px-2 py-1 text-[10px] text-text-muted">{card.risk_gate || "通过"}</span>
|
||||||
|
<span className="rounded-lg bg-bg-primary/40 px-2 py-1 text-[10px] text-text-muted">{card.theme || "未归类"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 line-clamp-2 text-xs leading-5 text-text-secondary">
|
||||||
|
{card.alpha_reason || card.logic_summary || card.trigger || "等待触发条件确认。"}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlphaMiniStat({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-bg-primary/45 px-2 py-2">
|
||||||
|
<div className="text-[10px] text-text-muted">{label}</div>
|
||||||
|
<div className="mt-1 font-mono text-sm font-semibold text-text-primary">{Math.round(value || 0)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function alphaRiskClass(riskGate?: string) {
|
||||||
|
if (riskGate === "否决") return "border-red-500/15 bg-red-500/[0.08] text-red-300";
|
||||||
|
if (riskGate === "预警") return "border-amber-500/15 bg-amber-500/[0.08] text-amber-300";
|
||||||
|
return "border-emerald-500/15 bg-emerald-500/[0.08] text-emerald-300";
|
||||||
|
}
|
||||||
|
|
||||||
function PerformanceReviewCard({
|
function PerformanceReviewCard({
|
||||||
performance,
|
performance,
|
||||||
|
researchReview,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
updating,
|
updating,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: {
|
}: {
|
||||||
performance: PerformanceStats | null;
|
performance: PerformanceStats | null;
|
||||||
|
researchReview: ResearchReviewStats | null;
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
updating: boolean;
|
updating: boolean;
|
||||||
onUpdate: () => void;
|
onUpdate: () => void;
|
||||||
@ -505,6 +597,8 @@ function PerformanceReviewCard({
|
|||||||
<SummaryMetric label="平均回撤" value={performance?.avg_max_drawdown ?? 0} tone="text-amber-400" suffix="%" />
|
<SummaryMetric label="平均回撤" value={performance?.avg_max_drawdown ?? 0} tone="text-amber-400" suffix="%" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{researchReview ? <ResearchEffectivenessPanel review={researchReview} /> : null}
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 xl:grid-cols-[320px_minmax(0,1fr)] gap-3">
|
<div className="mt-4 grid grid-cols-1 xl:grid-cols-[320px_minmax(0,1fr)] gap-3">
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/60 p-3">
|
<div className="rounded-2xl border border-border-subtle bg-surface-1/60 p-3">
|
||||||
<div className="text-[11px] font-semibold text-text-secondary">有效路线</div>
|
<div className="text-[11px] font-semibold text-text-secondary">有效路线</div>
|
||||||
@ -564,6 +658,81 @@ function PerformanceReviewCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ResearchEffectivenessPanel({ review }: { review: ResearchReviewStats }) {
|
||||||
|
const blocks = [
|
||||||
|
{ title: "有效主题", items: review.theme_breakdown.slice(0, 3) },
|
||||||
|
{ title: "有效环节", items: review.chain_breakdown.slice(0, 3) },
|
||||||
|
{ title: "有效信号", items: review.signal_breakdown.slice(0, 3).map((item) => ({ ...item, label: formatSignalLabel(item.label) })) },
|
||||||
|
{ title: "风险影响", items: review.risk_breakdown.slice(0, 3), risk: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 rounded-2xl border border-border-subtle bg-surface-1/60 p-3">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">Research Review</div>
|
||||||
|
<div className="mt-1 text-sm font-semibold text-text-primary">{review.summary.headline}</div>
|
||||||
|
<div className="mt-1 text-xs text-text-muted">
|
||||||
|
{review.tracked_count} 个跟踪样本 / {review.sample_count} 个研究样本
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-surface-2/70 px-3 py-2 text-right">
|
||||||
|
<div className="text-[10px] text-text-muted">窗口</div>
|
||||||
|
<div className="mt-0.5 font-mono text-sm font-semibold text-text-primary">{review.days}天</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid grid-cols-1 gap-2 lg:grid-cols-4">
|
||||||
|
{blocks.map((block) => (
|
||||||
|
<ReviewBreakdownBlock key={block.title} title={block.title} items={block.items} risk={block.risk} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||||
|
{review.summary.suggestions.slice(0, 4).map((item, index) => (
|
||||||
|
<div key={`${item}-${index}`} className="rounded-xl bg-surface-2/55 px-3 py-2 text-xs leading-5 text-text-secondary">
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewBreakdownBlock({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
risk,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
items: ResearchReviewStats["theme_breakdown"];
|
||||||
|
risk?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border-subtle bg-surface-2/35 p-3">
|
||||||
|
<div className="text-[11px] font-semibold text-text-secondary">{title}</div>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{items.length ? items.map((item) => (
|
||||||
|
<div key={item.label} className="rounded-lg bg-surface-1/70 px-2.5 py-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="truncate text-xs font-medium text-text-primary">{item.label || "未归类"}</div>
|
||||||
|
<div className={`font-mono text-xs tabular-nums ${item.avg_return >= 0 && !risk ? "text-red-400" : item.avg_return < 0 ? "text-emerald-400" : "text-amber-400"}`}>
|
||||||
|
{formatSigned(item.avg_return)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center justify-between text-[10px] text-text-muted">
|
||||||
|
<span>{item.tracked_count}/{item.sample_count} 样本</span>
|
||||||
|
<span>胜率 {item.win_rate}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="rounded-lg bg-surface-1/70 px-2 py-5 text-center text-xs text-text-muted">暂无样本</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ReviewRow({ item }: { item: TrackedRecommendation }) {
|
function ReviewRow({ item }: { item: TrackedRecommendation }) {
|
||||||
const pct = item.pct_from_entry ?? 0;
|
const pct = item.pct_from_entry ?? 0;
|
||||||
const pctTone = pct >= 0 ? "text-red-400" : "text-emerald-400";
|
const pctTone = pct >= 0 ? "text-red-400" : "text-emerald-400";
|
||||||
@ -626,6 +795,19 @@ function formatCloseReason(reason?: string) {
|
|||||||
return labels[reason ?? ""] ?? "跟踪中";
|
return labels[reason ?? ""] ?? "跟踪中";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSignalLabel(signal?: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
breakout: "突破",
|
||||||
|
breakout_confirm: "突破确认",
|
||||||
|
pullback: "回踩",
|
||||||
|
launch: "启动",
|
||||||
|
reversal: "反转",
|
||||||
|
flow_momentum: "资金动量",
|
||||||
|
none: "未归类",
|
||||||
|
};
|
||||||
|
return labels[signal ?? ""] ?? signal ?? "未归类";
|
||||||
|
}
|
||||||
|
|
||||||
function formatNumber(value: number | null | undefined) {
|
function formatNumber(value: number | null | undefined) {
|
||||||
return value == null ? "-" : Number(value).toFixed(2);
|
return value == null ? "-" : Number(value).toFixed(2);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { fetchAPI } from "@/lib/api";
|
import { fetchAPI } from "@/lib/api";
|
||||||
import type { LeadingStock, SectorData } from "@/lib/api";
|
import type { LeadingStock, ResearchReport, SectorData, ThemeChainItem } from "@/lib/api";
|
||||||
import { formatNumber } from "@/lib/utils";
|
import { formatNumber } from "@/lib/utils";
|
||||||
import { ErrorBoundary } from "@/components/error-boundary";
|
import { ErrorBoundary } from "@/components/error-boundary";
|
||||||
import { useWebSocket } from "@/hooks/use-websocket";
|
import { useWebSocket } from "@/hooks/use-websocket";
|
||||||
@ -365,14 +365,168 @@ function MetricBox({ label, children }: { label: string; children: React.ReactNo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IndustryChainMatrix({ themes }: { themes: ResearchReport["theme_views"] }) {
|
||||||
|
const nodeCount = themes.reduce((sum, theme) => sum + normalizedChainItems(theme).length, 0);
|
||||||
|
const mappedStockCount = themes.reduce(
|
||||||
|
(sum, theme) => sum + normalizedChainItems(theme).reduce((nodeSum, node) => nodeSum + node.leader_stocks.length + node.related_stocks.length, 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="glass-card-static overflow-hidden animate-fade-in-up">
|
||||||
|
<div className="flex flex-col gap-3 border-b border-border-subtle px-4 py-4 md:flex-row md:items-end md:justify-between md:px-5">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">Industry Chain</div>
|
||||||
|
<h3 className="mt-1 text-base font-bold text-text-primary">产业链扩散路径</h3>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-text-muted">从主题到环节,再到核心股和相关股。</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-right">
|
||||||
|
<MatrixMetric label="主题" value={themes.length} />
|
||||||
|
<MatrixMetric label="环节" value={nodeCount} />
|
||||||
|
<MatrixMetric label="标的" value={mappedStockCount} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-border-subtle">
|
||||||
|
{themes.map((theme) => (
|
||||||
|
<ThemeChainRow key={`${theme.theme}-${theme.raw_sector}`} theme={theme} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeChainRow({ theme }: { theme: ResearchReport["theme_views"][number] }) {
|
||||||
|
const chainItems = normalizedChainItems(theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="grid gap-4 px-4 py-4 md:px-5 xl:grid-cols-[230px_minmax(0,1fr)]">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-3 xl:block">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-text-primary">{theme.theme}</div>
|
||||||
|
<div className="mt-1 text-[11px] text-text-muted">
|
||||||
|
{theme.lifecycle_status || theme.stage} · {theme.raw_sector || theme.theme}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-base font-semibold tabular-nums text-amber-400 xl:mt-3">
|
||||||
|
{Math.round(theme.heat_score)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 line-clamp-3 text-xs leading-5 text-text-secondary">{theme.logic}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 md:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
{chainItems.slice(0, 9).map((item) => (
|
||||||
|
<ChainNodeBlock key={`${theme.theme}-${item.chain_node}`} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChainNodeBlock({ item }: { item: ThemeChainItem }) {
|
||||||
|
const leaders = item.leader_stocks.slice(0, 4);
|
||||||
|
const related = item.related_stocks.slice(0, 4);
|
||||||
|
const hasStocks = leaders.length || related.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border-subtle bg-surface-1/60 px-3 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="truncate text-xs font-semibold text-text-primary">{item.chain_node}</div>
|
||||||
|
{item.node_role ? (
|
||||||
|
<span className="shrink-0 rounded-md border border-amber-500/15 bg-amber-500/[0.06] px-1.5 py-0.5 text-[10px] text-amber-300">
|
||||||
|
{item.node_role}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{hasStocks ? (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{leaders.length ? <StockStrip label="核心" stocks={leaders} tone="core" /> : null}
|
||||||
|
{related.length ? <StockStrip label="相关" stocks={related} tone="related" /> : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 rounded-lg bg-surface-2/45 px-2 py-2 text-[11px] text-text-muted">
|
||||||
|
待维护标的
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StockStrip({
|
||||||
|
label,
|
||||||
|
stocks,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
stocks: ThemeChainItem["leader_stocks"];
|
||||||
|
tone: "core" | "related";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={`mb-1 text-[10px] font-semibold ${tone === "core" ? "text-red-300" : "text-text-muted"}`}>{label}</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{stocks.map((stock, index) => <StockTag key={`${stockLabel(stock)}-${index}`} stock={stock} tone={tone} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StockTag({ stock, tone }: { stock: string | { name?: string; ts_code?: string }; tone: "core" | "related" }) {
|
||||||
|
const label = stockLabel(stock);
|
||||||
|
const code = typeof stock === "string" ? "" : stock.ts_code || "";
|
||||||
|
const className = tone === "core"
|
||||||
|
? "border-red-500/15 bg-red-500/[0.06] text-red-200"
|
||||||
|
: "border-border-subtle bg-surface-2/70 text-text-secondary";
|
||||||
|
if (code) {
|
||||||
|
return (
|
||||||
|
<Link href={`/stock/${code}`} className={`rounded-lg border px-2 py-1 text-[10px] transition-colors hover:border-amber-500/20 hover:text-amber-300 ${className}`}>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className={`rounded-lg border px-2 py-1 text-[10px] ${className}`}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatrixMetric({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-surface-2/60 px-3 py-2">
|
||||||
|
<div className="text-[10px] text-text-muted">{label}</div>
|
||||||
|
<div className="mt-0.5 font-mono text-sm font-semibold tabular-nums text-text-primary">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedChainItems(theme: ResearchReport["theme_views"][number]): ThemeChainItem[] {
|
||||||
|
const explicitItems = (theme.chain_items ?? []).filter((item) => item.chain_node);
|
||||||
|
if (explicitItems.length) return explicitItems;
|
||||||
|
return theme.chain_nodes.map((node) => ({
|
||||||
|
chain_node: node,
|
||||||
|
leader_stocks: [],
|
||||||
|
related_stocks: [],
|
||||||
|
node_role: "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stockLabel(stock: string | { name?: string; ts_code?: string }) {
|
||||||
|
if (typeof stock === "string") return stock;
|
||||||
|
return stock.name || stock.ts_code || "-";
|
||||||
|
}
|
||||||
|
|
||||||
export default function SectorsPage() {
|
export default function SectorsPage() {
|
||||||
const [sectors, setSectors] = useState<SectorData[]>([]);
|
const [sectors, setSectors] = useState<SectorData[]>([]);
|
||||||
|
const [research, setResearch] = useState<ResearchReport | null>(null);
|
||||||
const [stageFilter, setStageFilter] = useState<string>("all");
|
const [stageFilter, setStageFilter] = useState<string>("all");
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchAPI<SectorData[]>("/api/sectors/hot?limit=20");
|
const [data, researchData] = await Promise.all([
|
||||||
|
fetchAPI<SectorData[]>("/api/sectors/hot?limit=20"),
|
||||||
|
fetchAPI<ResearchReport>("/api/research/today").catch(() => null),
|
||||||
|
]);
|
||||||
setSectors(data);
|
setSectors(data);
|
||||||
|
setResearch(researchData);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@ -416,7 +570,8 @@ export default function SectorsPage() {
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||||
<div className="animate-fade-in-up">
|
<div className="animate-fade-in-up">
|
||||||
<h1 className="text-lg font-bold tracking-tight">主线主题</h1>
|
<h1 className="text-lg font-bold tracking-tight">主题图谱</h1>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">从板块强度升级到主题、产业链环节和前排公司。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!sectors.length ? (
|
{!sectors.length ? (
|
||||||
@ -447,6 +602,8 @@ export default function SectorsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{research?.theme_views?.length ? <IndustryChainMatrix themes={research.theme_views.slice(0, 6)} /> : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[1.15fr_0.95fr_0.8fr] gap-4 animate-fade-in-up">
|
<div className="grid grid-cols-1 xl:grid-cols-[1.15fr_0.95fr_0.8fr] gap-4 animate-fade-in-up">
|
||||||
<LaneCard
|
<LaneCard
|
||||||
title={hasPositiveLeader ? "今日主线" : "相对抗跌"}
|
title={hasPositiveLeader ? "今日主线" : "相对抗跌"}
|
||||||
|
|||||||
@ -63,14 +63,14 @@ export default function SentimentPage() {
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||||
<div className="animate-fade-in-up">
|
<div className="animate-fade-in-up">
|
||||||
<h1 className="text-lg font-bold tracking-tight">舆情雷达</h1>
|
<h1 className="text-lg font-bold tracking-tight">催化雷达</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px] gap-4 animate-fade-in-up">
|
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px] gap-4 animate-fade-in-up">
|
||||||
<section className="glass-card-static p-4 md:p-5">
|
<section className="glass-card-static p-4 md:p-5">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">市场情绪线索</div>
|
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">主题催化线索</div>
|
||||||
<h2 className="mt-2 text-xl font-bold tracking-tight text-text-primary">{headline.title}</h2>
|
<h2 className="mt-2 text-xl font-bold tracking-tight text-text-primary">{headline.title}</h2>
|
||||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-text-secondary">{headline.detail}</p>
|
<p className="mt-2 max-w-3xl text-sm leading-6 text-text-secondary">{headline.detail}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -108,7 +108,7 @@ export default function SentimentPage() {
|
|||||||
<section className="glass-card-static p-4 md:p-5">
|
<section className="glass-card-static p-4 md:p-5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-text-primary">舆情流</h2>
|
<h2 className="text-sm font-semibold text-text-primary">催化流</h2>
|
||||||
<p className="mt-1 text-xs text-text-muted">后台抓取的新闻、政策和公告线索。</p>
|
<p className="mt-1 text-xs text-text-muted">后台抓取的新闻、政策和公告线索。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@ -159,7 +159,7 @@ export default function SentimentPage() {
|
|||||||
<section className="glass-card-static p-4">
|
<section className="glass-card-static p-4">
|
||||||
<h2 className="text-sm font-semibold text-text-primary">使用边界</h2>
|
<h2 className="text-sm font-semibold text-text-primary">使用边界</h2>
|
||||||
<div className="mt-3 space-y-2 text-xs leading-6 text-text-secondary">
|
<div className="mt-3 space-y-2 text-xs leading-6 text-text-secondary">
|
||||||
<BoundaryLine text="舆情只解释催化方向,不直接给出买卖。" />
|
<BoundaryLine text="催化只解释主题方向,不直接给出买卖。" />
|
||||||
<BoundaryLine text="主题催化需要和资金流、前排强度共同确认。" />
|
<BoundaryLine text="主题催化需要和资金流、前排强度共同确认。" />
|
||||||
<BoundaryLine text="页面只读取本地数据库,不触发外部抓取。" />
|
<BoundaryLine text="页面只读取本地数据库,不触发外部抓取。" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
214
frontend/src/app/(auth)/system/page.tsx
Normal file
214
frontend/src/app/(auth)/system/page.tsx
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { getDataSourceHealthAPI, getErrorLogsAPI, getSystemStatusAPI, getTaskCenterAPI, type DataSourceHealthResult, type ErrorLogsResult, type SystemStatus, type TaskCenterResult } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
|
||||||
|
export default function SystemPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||||
|
const [tasks, setTasks] = useState<TaskCenterResult | null>(null);
|
||||||
|
const [errors, setErrors] = useState<ErrorLogsResult | null>(null);
|
||||||
|
const [health, setHealth] = useState<DataSourceHealthResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [systemResult, taskResult, errorResult, healthResult] = await Promise.all([
|
||||||
|
getSystemStatusAPI().catch(() => null),
|
||||||
|
getTaskCenterAPI().catch(() => null),
|
||||||
|
getErrorLogsAPI(20, undefined, undefined, 7).catch(() => null),
|
||||||
|
getDataSourceHealthAPI(7).catch(() => null),
|
||||||
|
]);
|
||||||
|
setStatus(systemResult);
|
||||||
|
setTasks(taskResult);
|
||||||
|
setErrors(errorResult);
|
||||||
|
setHealth(healthResult);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
if (user?.role !== "admin") {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl px-4 pb-20 pt-8 md:px-8">
|
||||||
|
<div className="glass-card-static p-6 text-sm text-text-secondary">当前账号没有运行状态权限。</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorCount = errors?.total ?? status?.today_error_count ?? 0;
|
||||||
|
const running = tasks?.scheduler_running;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl space-y-5 px-4 pb-20 pt-6 md:px-8 md:pb-10">
|
||||||
|
<header className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold tracking-tight text-text-primary">运行状态</h1>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">看系统有没有正常工作,以及最近哪里出过问题。</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={loadData} className="rounded-xl border border-border-subtle bg-surface-2 px-4 py-2 text-xs text-text-secondary hover:text-text-primary">
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-48 animate-shimmer rounded-2xl bg-surface-2" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="grid gap-3 md:grid-cols-4">
|
||||||
|
<StatusCard label="系统" value={running ? "运行中" : "未确认"} tone={running ? "good" : "warn"} />
|
||||||
|
<StatusCard label="扫描" value={tasks?.scan_running ? "正在扫描" : "空闲"} tone={tasks?.scan_running ? "warn" : "good"} />
|
||||||
|
<StatusCard label="最近问题" value={`${errorCount}`} tone={errorCount ? "bad" : "good"} />
|
||||||
|
<StatusCard label="数据库" value={`${status?.db_size_mb ?? 0}MB`} tone="neutral" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
|
<div className="glass-card-static overflow-hidden">
|
||||||
|
<div className="border-b border-border-subtle px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">最近扫描</h2>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">系统最近做过的动作。</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border-subtle">
|
||||||
|
{tasks?.recent_scan_logs?.length ? tasks.recent_scan_logs.slice(0, 10).map((item, index) => (
|
||||||
|
<div key={`${item.scan_session}-${index}`} className="grid gap-2 px-4 py-3 md:grid-cols-[150px_minmax(0,1fr)_90px] md:items-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-text-primary">{plainStage(item.stage)}</div>
|
||||||
|
<div className="mt-1 text-[11px] text-text-muted">{formatTime(item.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm leading-6 text-text-secondary">{item.summary || "完成"}</div>
|
||||||
|
<div className="text-right font-mono text-xs text-text-muted">{item.output_count}</div>
|
||||||
|
</div>
|
||||||
|
)) : <Empty text="暂无扫描记录" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="glass-card-static p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">定时任务</h2>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{tasks?.jobs?.slice(0, 6).map((job) => (
|
||||||
|
<div key={job.id} className="rounded-xl bg-surface-2/55 px-3 py-2">
|
||||||
|
<div className="truncate text-xs font-medium text-text-primary">{job.name}</div>
|
||||||
|
<div className="mt-1 text-[11px] text-text-muted">{job.next_run_time || "等待安排"}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-card-static p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">数据源问题</h2>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{health?.sources?.length ? health.sources.slice(0, 6).map((item) => (
|
||||||
|
<div key={item.source} className="rounded-xl bg-surface-2/55 px-3 py-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="truncate text-xs font-medium text-text-primary">{item.source}</div>
|
||||||
|
<span className={`rounded-md px-1.5 py-0.5 text-[10px] ${item.error_count ? "bg-red-500/10 text-red-300" : "bg-amber-500/10 text-amber-300"}`}>
|
||||||
|
{item.error_count ? `${item.error_count}错` : `${item.warning_count}警`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 line-clamp-2 text-[11px] leading-5 text-text-muted">{item.last_error || "无异常"}</div>
|
||||||
|
</div>
|
||||||
|
)) : <div className="text-xs text-text-muted">最近没有记录到数据源异常。</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-card-static p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">数据是否新鲜</h2>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{Object.entries(health?.freshness ?? {}).length ? Object.entries(health?.freshness ?? {}).map(([key, item]) => (
|
||||||
|
<div key={key} className="rounded-xl bg-surface-2/55 px-3 py-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="truncate text-xs font-medium text-text-primary">{item.label || key}</div>
|
||||||
|
<span className={`rounded-md px-1.5 py-0.5 text-[10px] ${freshnessTone(item.status)}`}>
|
||||||
|
{freshnessLabel(item.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] leading-5 text-text-muted">
|
||||||
|
{item.message || "等待更新"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) : <div className="text-xs text-text-muted">暂无数据新鲜度记录。</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="glass-card-static overflow-hidden">
|
||||||
|
<div className="border-b border-border-subtle px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">最近错误</h2>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">用于快速定位系统异常。</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border-subtle">
|
||||||
|
{(errors?.errors ?? errors?.logs)?.length ? (errors?.errors ?? errors?.logs ?? []).slice(0, 12).map((item) => (
|
||||||
|
<div key={item.id} className="grid gap-2 px-4 py-3 md:grid-cols-[150px_minmax(0,1fr)_130px] md:items-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-text-primary">{item.source}</div>
|
||||||
|
<div className="mt-1 text-[11px] text-text-muted">{item.level}</div>
|
||||||
|
</div>
|
||||||
|
<div className="line-clamp-2 text-sm leading-6 text-text-secondary">{item.message}</div>
|
||||||
|
<div className="text-right text-[11px] text-text-muted">{formatTime(item.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
)) : <Empty text="最近没有错误记录" />}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusCard({ label, value, tone }: { label: string; value: string; tone: "good" | "warn" | "bad" | "neutral" }) {
|
||||||
|
const cls = {
|
||||||
|
good: "text-emerald-300",
|
||||||
|
warn: "text-amber-300",
|
||||||
|
bad: "text-red-300",
|
||||||
|
neutral: "text-text-primary",
|
||||||
|
}[tone];
|
||||||
|
return (
|
||||||
|
<div className="glass-card-static p-4">
|
||||||
|
<div className="text-[10px] text-text-muted">{label}</div>
|
||||||
|
<div className={`mt-2 text-xl font-bold ${cls}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty({ text }: { text: string }) {
|
||||||
|
return <div className="p-6 text-sm text-text-muted">{text}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainStage(stage: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
final_filter: "留下最终名单",
|
||||||
|
realtime: "刷新行情",
|
||||||
|
sector: "更新主线",
|
||||||
|
llm: "整理研究理由",
|
||||||
|
};
|
||||||
|
return labels[stage] ?? stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function freshnessLabel(status?: string) {
|
||||||
|
if (status === "ok") return "正常";
|
||||||
|
if (status === "stale") return "过期";
|
||||||
|
if (status === "missing") return "暂无";
|
||||||
|
return "未知";
|
||||||
|
}
|
||||||
|
|
||||||
|
function freshnessTone(status?: string) {
|
||||||
|
if (status === "ok") return "bg-emerald-500/10 text-emerald-300";
|
||||||
|
if (status === "stale") return "bg-amber-500/10 text-amber-300";
|
||||||
|
if (status === "missing") return "bg-red-500/10 text-red-300";
|
||||||
|
return "bg-surface-2 text-text-muted";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value?: string) {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value.includes("T") ? value : value.replace(" ", "T"));
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
@ -70,6 +70,26 @@ function AdminIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TimelineIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M4 5h4" />
|
||||||
|
<path d="M4 12h10" />
|
||||||
|
<path d="M4 19h16" />
|
||||||
|
<circle cx="17" cy="12" r="2" />
|
||||||
|
<circle cx="10" cy="5" r="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PulseIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 12h4l2-6 4 12 2-6h6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function SideNavItem({ href, icon, label, onNavigate }: { href: string; icon: React.ReactNode; label: string; onNavigate?: () => void }) {
|
function SideNavItem({ href, icon, label, onNavigate }: { href: string; icon: React.ReactNode; label: string; onNavigate?: () => void }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@ -96,10 +116,14 @@ export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex-1 overflow-y-auto px-2 sm:px-3 py-4 sm:py-5 space-y-1">
|
<nav className="flex-1 overflow-y-auto px-2 sm:px-3 py-4 sm:py-5 space-y-1">
|
||||||
<SideNavItem href="/dashboard" icon={<DashboardIcon />} label="今日作战" onNavigate={onNavigate} />
|
<SideNavItem href="/dashboard" icon={<DashboardIcon />} label="今天怎么看" onNavigate={onNavigate} />
|
||||||
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐池" onNavigate={onNavigate} />
|
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="机会清单" onNavigate={onNavigate} />
|
||||||
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块主线" onNavigate={onNavigate} />
|
<SideNavItem href="/sectors" icon={<FireIcon />} label="市场主线" onNavigate={onNavigate} />
|
||||||
<SideNavItem href="/sentiment" icon={<RadarIcon />} label="舆情雷达" onNavigate={onNavigate} />
|
<SideNavItem href="/sentiment" icon={<RadarIcon />} label="消息催化" onNavigate={onNavigate} />
|
||||||
|
<SideNavItem href="/agents" icon={<TimelineIcon />} label="工作记录" onNavigate={onNavigate} />
|
||||||
|
{user?.role === "admin" ? (
|
||||||
|
<SideNavItem href="/system" icon={<PulseIcon />} label="运行状态" onNavigate={onNavigate} />
|
||||||
|
) : null}
|
||||||
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" onNavigate={onNavigate} />
|
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" onNavigate={onNavigate} />
|
||||||
<SideNavItem href="/chat" icon={<ChatIcon />} label="研究助手" onNavigate={onNavigate} />
|
<SideNavItem href="/chat" icon={<ChatIcon />} label="研究助手" onNavigate={onNavigate} />
|
||||||
{user?.role === "admin" ? (
|
{user?.role === "admin" ? (
|
||||||
|
|||||||
@ -93,6 +93,29 @@ export async function patchAPI<T>(path: string, body?: unknown): Promise<T> {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function putAPI<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
const token = getAuthToken();
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
handleUnauthorized();
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || `API error: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export interface MarketTemperatureData {
|
export interface MarketTemperatureData {
|
||||||
trade_date: string;
|
trade_date: string;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
@ -401,6 +424,158 @@ export interface DayGroup {
|
|||||||
elimination_reasons?: Record<string, number>;
|
elimination_reasons?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResearchReport {
|
||||||
|
trade_date: string;
|
||||||
|
scan_session: string;
|
||||||
|
scan_mode?: string;
|
||||||
|
scanned_at: string;
|
||||||
|
market_view: {
|
||||||
|
regime: string;
|
||||||
|
confidence: number;
|
||||||
|
summary: string;
|
||||||
|
temperature?: number;
|
||||||
|
up_count?: number;
|
||||||
|
down_count?: number;
|
||||||
|
limit_up_count?: number;
|
||||||
|
limit_down_count?: number;
|
||||||
|
};
|
||||||
|
theme_views: ResearchThemeView[];
|
||||||
|
industry_chain_map: Array<{
|
||||||
|
theme: string;
|
||||||
|
chain_nodes: string[];
|
||||||
|
chain_items?: ThemeChainItem[];
|
||||||
|
leader_stocks?: Array<{ name?: string; ts_code?: string; pct_chg?: number }>;
|
||||||
|
}>;
|
||||||
|
catalyst?: {
|
||||||
|
summary: string;
|
||||||
|
strong_theme_count: number;
|
||||||
|
themes: string[];
|
||||||
|
};
|
||||||
|
stock_research_notes?: ResearchStockNote[];
|
||||||
|
opportunity_cards: ResearchOpportunityCard[];
|
||||||
|
risk_alerts: ResearchRiskAlert[];
|
||||||
|
risk_summary?: {
|
||||||
|
reject_count: number;
|
||||||
|
warning_count: number;
|
||||||
|
types: string[];
|
||||||
|
};
|
||||||
|
data_quality?: {
|
||||||
|
status: string;
|
||||||
|
market_data_status?: string;
|
||||||
|
market_source?: string;
|
||||||
|
limit_counts_reliable?: boolean;
|
||||||
|
warnings?: string[];
|
||||||
|
issues?: Array<{
|
||||||
|
source: string;
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
created_at: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
ranking_feedback?: {
|
||||||
|
days?: number;
|
||||||
|
sample_count?: number;
|
||||||
|
tracked_count?: number;
|
||||||
|
summary?: {
|
||||||
|
headline?: string;
|
||||||
|
suggestions?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
no_trade_reason: {
|
||||||
|
has_scan: boolean;
|
||||||
|
reason: string;
|
||||||
|
blocked_by?: string[];
|
||||||
|
};
|
||||||
|
summary?: {
|
||||||
|
market?: string;
|
||||||
|
theme?: string;
|
||||||
|
opportunity_count?: number;
|
||||||
|
risk_count?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchThemeView {
|
||||||
|
theme: string;
|
||||||
|
raw_sector?: string;
|
||||||
|
stage: string;
|
||||||
|
heat_score: number;
|
||||||
|
pct_change: number;
|
||||||
|
limit_up_count: number;
|
||||||
|
logic: string;
|
||||||
|
chain_nodes: string[];
|
||||||
|
chain_items?: ThemeChainItem[];
|
||||||
|
leader_stocks?: Array<{ name?: string; ts_code?: string; pct_chg?: number }>;
|
||||||
|
lifecycle_status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeChainItem {
|
||||||
|
chain_node: string;
|
||||||
|
related_stocks: Array<string | { name?: string; ts_code?: string }>;
|
||||||
|
leader_stocks: Array<string | { name?: string; ts_code?: string }>;
|
||||||
|
node_role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchOpportunityCard {
|
||||||
|
ts_code: string;
|
||||||
|
name: string;
|
||||||
|
theme: string;
|
||||||
|
chain_node: string;
|
||||||
|
stock_role?: string;
|
||||||
|
opportunity_type: string;
|
||||||
|
score: number;
|
||||||
|
adjusted_score?: number;
|
||||||
|
alpha_type?: string;
|
||||||
|
alpha_score?: number;
|
||||||
|
beta_dependency?: string;
|
||||||
|
beta_dependency_score?: number;
|
||||||
|
ambush_score?: number;
|
||||||
|
expectation_gap_score?: number;
|
||||||
|
risk_gate?: string;
|
||||||
|
setup_quality?: string;
|
||||||
|
alpha_reason?: string;
|
||||||
|
review_adjustment?: number;
|
||||||
|
review_feedback?: Array<{ source: string; label: string; delta: number }>;
|
||||||
|
logic_score?: number;
|
||||||
|
action_plan: string;
|
||||||
|
trigger: string;
|
||||||
|
invalid_condition: string;
|
||||||
|
logic_summary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchRiskAlert {
|
||||||
|
ts_code: string;
|
||||||
|
risk_type: string;
|
||||||
|
severity: string;
|
||||||
|
reject: boolean;
|
||||||
|
reason: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchStockNote {
|
||||||
|
ts_code: string;
|
||||||
|
name: string;
|
||||||
|
theme: string;
|
||||||
|
chain_node: string;
|
||||||
|
stock_role?: string;
|
||||||
|
logic_score: number;
|
||||||
|
logic_summary: string;
|
||||||
|
evidence: string[];
|
||||||
|
uncertainty: string;
|
||||||
|
disagreement?: string;
|
||||||
|
invalid_condition?: string;
|
||||||
|
generated_by?: "rules" | "llm" | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeKnowledgeItem {
|
||||||
|
theme: string;
|
||||||
|
aliases: string[];
|
||||||
|
logic: string;
|
||||||
|
stage: string;
|
||||||
|
lifecycle_status: string;
|
||||||
|
chain_nodes: string[];
|
||||||
|
chain_items?: ThemeChainItem[];
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Performance Stats ----------
|
// ---------- Performance Stats ----------
|
||||||
|
|
||||||
export interface PerformanceStats {
|
export interface PerformanceStats {
|
||||||
@ -419,6 +594,44 @@ export interface PerformanceStats {
|
|||||||
details: TrackedRecommendation[];
|
details: TrackedRecommendation[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResearchReviewStats {
|
||||||
|
days: number;
|
||||||
|
sample_count: number;
|
||||||
|
tracked_count: number;
|
||||||
|
theme_breakdown: ResearchReviewBreakdown[];
|
||||||
|
chain_breakdown: ResearchReviewBreakdown[];
|
||||||
|
signal_breakdown: ResearchReviewBreakdown[];
|
||||||
|
risk_breakdown: ResearchReviewBreakdown[];
|
||||||
|
summary: {
|
||||||
|
headline: string;
|
||||||
|
strongest_theme?: ResearchReviewBreakdown;
|
||||||
|
strongest_chain?: ResearchReviewBreakdown;
|
||||||
|
strongest_signal?: ResearchReviewBreakdown;
|
||||||
|
weakest_risk?: ResearchReviewBreakdown;
|
||||||
|
suggestions: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchReviewBreakdown {
|
||||||
|
label: string;
|
||||||
|
sample_count: number;
|
||||||
|
tracked_count: number;
|
||||||
|
win_rate: number;
|
||||||
|
avg_return: number;
|
||||||
|
avg_max_return: number;
|
||||||
|
avg_drawdown: number;
|
||||||
|
hit_target_count: number;
|
||||||
|
hit_stop_count: number;
|
||||||
|
effectiveness: number;
|
||||||
|
top_samples: Array<{
|
||||||
|
ts_code: string;
|
||||||
|
name: string;
|
||||||
|
pct_from_entry: number | null;
|
||||||
|
max_return_pct: number | null;
|
||||||
|
created_at: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrackedRecommendation {
|
export interface TrackedRecommendation {
|
||||||
recommendation_id?: number;
|
recommendation_id?: number;
|
||||||
ts_code: string;
|
ts_code: string;
|
||||||
@ -910,6 +1123,42 @@ export async function toggleInviteCodeAPI(inviteId: number): Promise<{ message:
|
|||||||
return postAPI<{ message: string }>(`/api/auth/invite-codes/${inviteId}/toggle`);
|
return postAPI<{ message: string }>(`/api/auth/invite-codes/${inviteId}/toggle`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listThemeKnowledgeAPI(): Promise<ThemeKnowledgeItem[]> {
|
||||||
|
return fetchAPI<ThemeKnowledgeItem[]>("/api/research/theme-knowledge");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateThemeKnowledgeAPI(themeName: string, item: ThemeKnowledgeItem): Promise<ThemeKnowledgeItem> {
|
||||||
|
return putAPI<ThemeKnowledgeItem>(`/api/research/theme-knowledge/${encodeURIComponent(themeName)}`, {
|
||||||
|
theme_name: item.theme,
|
||||||
|
aliases: item.aliases,
|
||||||
|
logic: item.logic,
|
||||||
|
lifecycle_status: item.lifecycle_status,
|
||||||
|
stage: item.stage,
|
||||||
|
chain_nodes: item.chain_nodes,
|
||||||
|
chain_items: normalizeThemeChainItems(item),
|
||||||
|
is_active: true,
|
||||||
|
sort_order: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeThemeChainItems(item: ThemeKnowledgeItem): ThemeChainItem[] {
|
||||||
|
const existing = item.chain_items ?? [];
|
||||||
|
if (existing.length) {
|
||||||
|
return existing.map((chainItem) => ({
|
||||||
|
chain_node: chainItem.chain_node,
|
||||||
|
related_stocks: chainItem.related_stocks ?? [],
|
||||||
|
leader_stocks: chainItem.leader_stocks ?? [],
|
||||||
|
node_role: chainItem.node_role ?? "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return item.chain_nodes.map((node) => ({
|
||||||
|
chain_node: node,
|
||||||
|
related_stocks: [],
|
||||||
|
leader_stocks: [],
|
||||||
|
node_role: "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export async function changePasswordAPI(oldPassword: string, newPassword: string): Promise<{ message: string }> {
|
export async function changePasswordAPI(oldPassword: string, newPassword: string): Promise<{ message: string }> {
|
||||||
return postAPI<{ message: string }>("/api/auth/change-password", {
|
return postAPI<{ message: string }>("/api/auth/change-password", {
|
||||||
old_password: oldPassword,
|
old_password: oldPassword,
|
||||||
@ -967,6 +1216,7 @@ export interface ErrorLog {
|
|||||||
export interface ErrorLogsResult {
|
export interface ErrorLogsResult {
|
||||||
total: number;
|
total: number;
|
||||||
errors: ErrorLog[];
|
errors: ErrorLog[];
|
||||||
|
logs?: ErrorLog[];
|
||||||
sources: string[];
|
sources: string[];
|
||||||
levels: string[];
|
levels: string[];
|
||||||
source_counts: Record<string, number>;
|
source_counts: Record<string, number>;
|
||||||
@ -974,13 +1224,19 @@ export interface ErrorLogsResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemStatus {
|
export interface SystemStatus {
|
||||||
is_trading: boolean;
|
status?: string;
|
||||||
scan_running: boolean;
|
generated_at?: string;
|
||||||
scan_locked: boolean;
|
is_trading?: boolean;
|
||||||
recent_errors: number;
|
scan_running?: boolean;
|
||||||
last_errors: { source: string; message: string; created_at: string }[];
|
scan_locked?: boolean;
|
||||||
tables_counts: Record<string, number>;
|
recent_errors?: number;
|
||||||
|
last_errors?: { source: string; message: string; created_at: string }[];
|
||||||
|
tables_counts?: Record<string, number>;
|
||||||
db_size_mb: number;
|
db_size_mb: number;
|
||||||
|
today_error_count?: number;
|
||||||
|
today_scan_log_count?: number;
|
||||||
|
latest_scan?: Record<string, unknown>;
|
||||||
|
latest_error?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScanProcessLog {
|
export interface ScanProcessLog {
|
||||||
@ -1058,7 +1314,16 @@ export interface DataSourceHealthItem {
|
|||||||
export interface DataSourceHealthResult {
|
export interface DataSourceHealthResult {
|
||||||
days: number;
|
days: number;
|
||||||
sources: DataSourceHealthItem[];
|
sources: DataSourceHealthItem[];
|
||||||
freshness: Record<string, Record<string, string>>;
|
freshness: Record<string, {
|
||||||
|
label?: string;
|
||||||
|
table?: string;
|
||||||
|
status?: string;
|
||||||
|
last_success_at?: string;
|
||||||
|
last_reference?: string;
|
||||||
|
total?: number;
|
||||||
|
age_minutes?: number | null;
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
generated_at: string;
|
generated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user