update
This commit is contained in:
parent
d6bae2c8b6
commit
2601a4db17
@ -1,7 +1,7 @@
|
|||||||
ASTOCK_TUSHARE_TOKEN=0ed6419a00d8923dc19c0b58fc92d94c9a0696949ab91a13aa58a0cc
|
ASTOCK_TUSHARE_TOKEN=0ed6419a00d8923dc19c0b58fc92d94c9a0696949ab91a13aa58a0cc
|
||||||
ASTOCK_DEBUG=true
|
ASTOCK_DEBUG=true
|
||||||
|
|
||||||
ASTOCK_DEEPSEEK_API_KEY=sk-9f6b56f08796435d988cf202e37f6ee3
|
ASTOCK_DEEPSEEK_API_KEY=sk-ee8eee63d5cf41eba14a328de49055ac
|
||||||
ASTOCK_ALERT_ENABLED=true
|
ASTOCK_ALERT_ENABLED=true
|
||||||
ASTOCK_FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/6307668f-10aa-4fc1-8c1e-bad1b6b78d4d
|
ASTOCK_FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/6307668f-10aa-4fc1-8c1e-bad1b6b78d4d
|
||||||
ASTOCK_ALERT_ENVIRONMENT=local
|
ASTOCK_ALERT_ENVIRONMENT=local
|
||||||
|
|||||||
@ -3,8 +3,14 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from app.catalyst.models import CatalystInput
|
from app.catalyst.models import CatalystInput
|
||||||
from app.catalyst.service import build_theme_catalyst_scores, get_recent_catalysts, ingest_catalyst
|
from app.catalyst.service import (
|
||||||
|
build_theme_catalyst_scores,
|
||||||
|
get_recent_catalysts,
|
||||||
|
get_recent_news_items,
|
||||||
|
ingest_catalyst,
|
||||||
|
)
|
||||||
from app.core.deps import get_current_admin
|
from app.core.deps import get_current_admin
|
||||||
|
from app.news.pipeline import refresh_news_catalysts
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/catalysts", tags=["catalysts"])
|
router = APIRouter(prefix="/api/catalysts", tags=["catalysts"])
|
||||||
|
|
||||||
@ -14,6 +20,11 @@ async def recent(limit: int = 30, hours: int = 72):
|
|||||||
return await get_recent_catalysts(limit=limit, hours=hours)
|
return await get_recent_catalysts(limit=limit, hours=hours)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/news")
|
||||||
|
async def news(limit: int = 50, hours: int = 24, status: str | None = None):
|
||||||
|
return await get_recent_news_items(limit=limit, hours=hours, status=status)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/theme-scores")
|
@router.get("/theme-scores")
|
||||||
async def theme_scores(hours: int = 72, limit: int = 20):
|
async def theme_scores(hours: int = 72, limit: int = 20):
|
||||||
scores = await build_theme_catalyst_scores(hours=hours, limit=limit)
|
scores = await build_theme_catalyst_scores(hours=hours, limit=limit)
|
||||||
@ -24,3 +35,8 @@ async def theme_scores(hours: int = 72, limit: int = 20):
|
|||||||
async def ingest(item: CatalystInput, _admin: dict = Depends(get_current_admin)):
|
async def ingest(item: CatalystInput, _admin: dict = Depends(get_current_admin)):
|
||||||
analysis = await ingest_catalyst(item, use_llm=True)
|
analysis = await ingest_catalyst(item, use_llm=True)
|
||||||
return analysis.model_dump()
|
return analysis.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh-news")
|
||||||
|
async def refresh_news(_admin: dict = Depends(get_current_admin)):
|
||||||
|
return await refresh_news_catalysts()
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from starlette.responses import StreamingResponse
|
|||||||
|
|
||||||
from app.data.tushare_client import tushare_client
|
from app.data.tushare_client import tushare_client
|
||||||
from app.data import tencent_client
|
from app.data import tencent_client
|
||||||
|
from app.data.code_utils import normalize_ts_code
|
||||||
from app.analysis.technical import add_all_indicators
|
from app.analysis.technical import add_all_indicators
|
||||||
from app.analysis.signals import generate_signals
|
from app.analysis.signals import generate_signals
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
@ -38,6 +39,7 @@ async def get_stock_thesis(ts_code: str):
|
|||||||
"""获取个股推荐推演归档(只读缓存,不触发扫描或 LLM)。"""
|
"""获取个股推荐推演归档(只读缓存,不触发扫描或 LLM)。"""
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
ts_code = normalize_ts_code(ts_code)
|
||||||
async with get_db() as db:
|
async with get_db() as db:
|
||||||
rec_result = await db.execute(
|
rec_result = await db.execute(
|
||||||
text(
|
text(
|
||||||
@ -139,7 +141,6 @@ async def get_stock_thesis(ts_code: str):
|
|||||||
decision_points = [
|
decision_points = [
|
||||||
{"label": "操作计划", "value": r["action_plan"] or "观察"},
|
{"label": "操作计划", "value": r["action_plan"] or "观察"},
|
||||||
{"label": "召回来源", "value": " / ".join(_safe_json_list(r.get("recall_tags"))) or "未归档"},
|
{"label": "召回来源", "value": " / ".join(_safe_json_list(r.get("recall_tags"))) or "未归档"},
|
||||||
{"label": "AI预筛", "value": r.get("prefilter_decision") or "未执行"},
|
|
||||||
{"label": "触发条件", "value": r["trigger_condition"] or "等待触发条件归档"},
|
{"label": "触发条件", "value": r["trigger_condition"] or "等待触发条件归档"},
|
||||||
{"label": "失效条件", "value": r["invalidation_condition"] or "等待失效条件归档"},
|
{"label": "失效条件", "value": r["invalidation_condition"] or "等待失效条件归档"},
|
||||||
{"label": "建议仓位", "value": f"{r['suggested_position_pct']}%" if r["suggested_position_pct"] is not None else "未设置"},
|
{"label": "建议仓位", "value": f"{r['suggested_position_pct']}%" if r["suggested_position_pct"] is not None else "未设置"},
|
||||||
@ -220,6 +221,7 @@ async def get_stock_thesis(ts_code: str):
|
|||||||
@router.get("/{ts_code}/quote")
|
@router.get("/{ts_code}/quote")
|
||||||
async def get_quote(ts_code: str):
|
async def get_quote(ts_code: str):
|
||||||
"""获取个股实时行情"""
|
"""获取个股实时行情"""
|
||||||
|
ts_code = normalize_ts_code(ts_code)
|
||||||
quote = await tencent_client.get_realtime_quote(ts_code)
|
quote = await tencent_client.get_realtime_quote(ts_code)
|
||||||
if not quote:
|
if not quote:
|
||||||
return {"error": "获取行情失败"}
|
return {"error": "获取行情失败"}
|
||||||
@ -229,6 +231,7 @@ async def get_quote(ts_code: str):
|
|||||||
@router.get("/{ts_code}/kline")
|
@router.get("/{ts_code}/kline")
|
||||||
async def get_kline(ts_code: str, days: int = 120):
|
async def get_kline(ts_code: str, days: int = 120):
|
||||||
"""获取个股K线数据(含技术指标)"""
|
"""获取个股K线数据(含技术指标)"""
|
||||||
|
ts_code = normalize_ts_code(ts_code)
|
||||||
df = tushare_client.get_stock_daily(ts_code, days=days)
|
df = tushare_client.get_stock_daily(ts_code, days=days)
|
||||||
if df.empty:
|
if df.empty:
|
||||||
return []
|
return []
|
||||||
@ -247,6 +250,7 @@ async def get_kline(ts_code: str, days: int = 120):
|
|||||||
@router.get("/{ts_code}/signals")
|
@router.get("/{ts_code}/signals")
|
||||||
async def get_signals(ts_code: str):
|
async def get_signals(ts_code: str):
|
||||||
"""获取个股技术面买卖信号"""
|
"""获取个股技术面买卖信号"""
|
||||||
|
ts_code = normalize_ts_code(ts_code)
|
||||||
signal = generate_signals(ts_code)
|
signal = generate_signals(ts_code)
|
||||||
return signal.model_dump()
|
return signal.model_dump()
|
||||||
|
|
||||||
@ -254,6 +258,7 @@ async def get_signals(ts_code: str):
|
|||||||
@router.get("/{ts_code}/capital_flow")
|
@router.get("/{ts_code}/capital_flow")
|
||||||
async def get_capital_flow(ts_code: str, days: int = 10):
|
async def get_capital_flow(ts_code: str, days: int = 10):
|
||||||
"""获取个股资金流向(含大/中/小单分拆)"""
|
"""获取个股资金流向(含大/中/小单分拆)"""
|
||||||
|
ts_code = normalize_ts_code(ts_code)
|
||||||
df = tushare_client.get_stock_moneyflow(ts_code, days=days)
|
df = tushare_client.get_stock_moneyflow(ts_code, days=days)
|
||||||
if df.empty:
|
if df.empty:
|
||||||
return []
|
return []
|
||||||
@ -287,6 +292,7 @@ async def get_capital_flow(ts_code: str, days: int = 10):
|
|||||||
@router.get("/{ts_code}/diagnose/history")
|
@router.get("/{ts_code}/diagnose/history")
|
||||||
async def get_diagnose_history(ts_code: str):
|
async def get_diagnose_history(ts_code: str):
|
||||||
"""获取个股最近5次诊断历史"""
|
"""获取个股最近5次诊断历史"""
|
||||||
|
ts_code = normalize_ts_code(ts_code)
|
||||||
try:
|
try:
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
async with get_db() as db:
|
async with get_db() as db:
|
||||||
@ -321,6 +327,7 @@ async def get_diagnose_history(ts_code: str):
|
|||||||
@router.post("/{ts_code}/diagnose")
|
@router.post("/{ts_code}/diagnose")
|
||||||
async def diagnose_stock(ts_code: str, mode: str = Query("entry")):
|
async def diagnose_stock(ts_code: str, mode: str = Query("entry")):
|
||||||
"""AI 诊断个股(SSE 流式返回)"""
|
"""AI 诊断个股(SSE 流式返回)"""
|
||||||
|
ts_code = normalize_ts_code(ts_code)
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
if not settings.deepseek_api_key:
|
if not settings.deepseek_api_key:
|
||||||
return {"status": "error", "message": "未配置 LLM API Key"}
|
return {"status": "error", "message": "未配置 LLM API Key"}
|
||||||
@ -484,7 +491,7 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")):
|
|||||||
"capital_score, technical_score, position_score, sector, signal, entry_signal_type "
|
"capital_score, technical_score, position_score, sector, signal, entry_signal_type "
|
||||||
"FROM recommendations "
|
"FROM recommendations "
|
||||||
"WHERE ts_code = :code "
|
"WHERE ts_code = :code "
|
||||||
"AND (action_plan IN ('可操作', '重点关注') OR COALESCE(llm_score, 0) >= 6 OR score >= 56) "
|
"AND (action_plan IN ('可操作', '重点关注') OR score >= 56) "
|
||||||
"ORDER BY created_at DESC LIMIT 1"
|
"ORDER BY created_at DESC LIMIT 1"
|
||||||
),
|
),
|
||||||
{"code": ts_code},
|
{"code": ts_code},
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
@ -11,6 +12,7 @@ from app.catalyst.mapper import analyze_catalyst
|
|||||||
from app.catalyst.models import CatalystAnalysis, CatalystInput, ThemeCatalystScore
|
from app.catalyst.models import CatalystAnalysis, CatalystInput, ThemeCatalystScore
|
||||||
from app.db import tables
|
from app.db import tables
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
|
from app.news.models import NewsItem
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -21,8 +23,26 @@ async def ingest_catalyst(item: CatalystInput, use_llm: bool = True) -> Catalyst
|
|||||||
return analysis
|
return analysis
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_catalyst_with_id(item: CatalystInput, use_llm: bool = True) -> tuple[CatalystAnalysis, int]:
|
||||||
|
analysis = await analyze_catalyst(item, use_llm=use_llm)
|
||||||
|
catalyst_id = await save_catalyst(analysis)
|
||||||
|
return analysis, catalyst_id
|
||||||
|
|
||||||
|
|
||||||
async def save_catalyst(analysis: CatalystAnalysis) -> int:
|
async def save_catalyst(analysis: CatalystAnalysis) -> int:
|
||||||
async with get_db() as db:
|
async with get_db() as db:
|
||||||
|
if analysis.url:
|
||||||
|
exists = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id FROM catalysts WHERE source = :source AND url = :url "
|
||||||
|
"ORDER BY id DESC LIMIT 1"
|
||||||
|
),
|
||||||
|
{"source": analysis.source, "url": analysis.url},
|
||||||
|
)
|
||||||
|
row = exists.fetchone()
|
||||||
|
if row:
|
||||||
|
return int(row._mapping["id"])
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
tables.catalysts_table.insert().values(
|
tables.catalysts_table.insert().values(
|
||||||
title=analysis.title,
|
title=analysis.title,
|
||||||
@ -58,6 +78,146 @@ async def save_catalyst(analysis: CatalystAnalysis) -> int:
|
|||||||
return catalyst_id
|
return catalyst_id
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_news_items(items: list[NewsItem]) -> dict:
|
||||||
|
"""保存原始新闻并分析新增项。"""
|
||||||
|
inserted = 0
|
||||||
|
duplicates = 0
|
||||||
|
analyzed = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
async with get_db() as db:
|
||||||
|
for item in items:
|
||||||
|
dedup_key = build_news_dedup_key(item)
|
||||||
|
exists = await db.execute(
|
||||||
|
text("SELECT id FROM news_items WHERE dedup_key = :dedup_key LIMIT 1"),
|
||||||
|
{"dedup_key": dedup_key},
|
||||||
|
)
|
||||||
|
if exists.fetchone():
|
||||||
|
duplicates += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
tables.news_items_table.insert().values(
|
||||||
|
title=item.title,
|
||||||
|
content=item.content,
|
||||||
|
summary=item.summary,
|
||||||
|
source=item.source,
|
||||||
|
url=item.url,
|
||||||
|
published_at=item.published_at,
|
||||||
|
dedup_key=dedup_key,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
if inserted:
|
||||||
|
result = await analyze_pending_news(limit=inserted)
|
||||||
|
analyzed += int(result.get("analyzed", 0))
|
||||||
|
failed += int(result.get("failed", 0))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"fetched": len(items),
|
||||||
|
"inserted": inserted,
|
||||||
|
"duplicates": duplicates,
|
||||||
|
"analyzed": analyzed,
|
||||||
|
"failed": failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_pending_news(limit: int = 50, use_llm: bool = True) -> dict:
|
||||||
|
rows = []
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT * FROM news_items "
|
||||||
|
"WHERE status = 'pending' "
|
||||||
|
"ORDER BY COALESCE(published_at, created_at) DESC, id DESC "
|
||||||
|
"LIMIT :limit"
|
||||||
|
),
|
||||||
|
{"limit": limit},
|
||||||
|
)
|
||||||
|
rows = [dict(row._mapping) for row in result.fetchall()]
|
||||||
|
|
||||||
|
analyzed = 0
|
||||||
|
skipped = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
title = row.get("title") or ""
|
||||||
|
content = row.get("content") or row.get("summary") or ""
|
||||||
|
try:
|
||||||
|
if not _looks_market_relevant(title, content):
|
||||||
|
await _mark_news_item(row["id"], status="skipped", error="")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
_, catalyst_id = await ingest_catalyst_with_id(
|
||||||
|
CatalystInput(
|
||||||
|
title=title,
|
||||||
|
content=content,
|
||||||
|
source=row.get("source") or "news",
|
||||||
|
url=row.get("url") or "",
|
||||||
|
published_at=row.get("published_at"),
|
||||||
|
),
|
||||||
|
use_llm=use_llm,
|
||||||
|
)
|
||||||
|
await _mark_news_item(
|
||||||
|
row["id"],
|
||||||
|
status="analyzed",
|
||||||
|
catalyst_id=catalyst_id,
|
||||||
|
error="",
|
||||||
|
)
|
||||||
|
analyzed += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("新闻催化分析失败 id=%s title=%s error=%s", row.get("id"), title, e)
|
||||||
|
await _mark_news_item(row["id"], status="failed", error=str(e)[:500])
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
return {"analyzed": analyzed, "skipped": skipped, "failed": failed}
|
||||||
|
|
||||||
|
|
||||||
|
def build_news_dedup_key(item: NewsItem) -> str:
|
||||||
|
text = f"{item.source}|{item.url or item.title}"
|
||||||
|
normalized = "".join(ch.lower() for ch in text.strip() if ch.isalnum() or ch in ".:/_-|")
|
||||||
|
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def _mark_news_item(
|
||||||
|
news_id: int,
|
||||||
|
status: str,
|
||||||
|
catalyst_id: int | None = None,
|
||||||
|
error: str = "",
|
||||||
|
) -> None:
|
||||||
|
async with get_db() as db:
|
||||||
|
await db.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE news_items SET status = :status, catalyst_id = :catalyst_id, "
|
||||||
|
"error = :error, analyzed_at = :analyzed_at WHERE id = :id"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"status": status,
|
||||||
|
"catalyst_id": catalyst_id,
|
||||||
|
"error": error,
|
||||||
|
"analyzed_at": datetime.now(),
|
||||||
|
"id": news_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_market_relevant(title: str, content: str) -> bool:
|
||||||
|
text_value = f"{title} {content}"
|
||||||
|
keywords = [
|
||||||
|
"A股", "股市", "上市公司", "证券", "券商", "板块", "行业", "概念",
|
||||||
|
"政策", "国务院", "发改委", "工信部", "央行", "证监会", "交易所",
|
||||||
|
"业绩", "订单", "签约", "并购", "重组", "回购", "定增", "涨停",
|
||||||
|
"资金", "主力", "北向", "算力", "人工智能", "半导体", "新能源",
|
||||||
|
"机器人", "低空", "医药", "消费", "军工", "地产", "有色",
|
||||||
|
]
|
||||||
|
return any(keyword in text_value for keyword in keywords)
|
||||||
|
|
||||||
|
|
||||||
async def get_recent_catalysts(limit: int = 30, hours: int = 72) -> list[dict]:
|
async def get_recent_catalysts(limit: int = 30, hours: int = 72) -> list[dict]:
|
||||||
since = datetime.now() - timedelta(hours=hours)
|
since = datetime.now() - timedelta(hours=hours)
|
||||||
async with get_db() as db:
|
async with get_db() as db:
|
||||||
@ -78,6 +238,27 @@ async def get_recent_catalysts(limit: int = 30, hours: int = 72) -> list[dict]:
|
|||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_recent_news_items(limit: int = 50, hours: int = 24, status: str | None = None) -> list[dict]:
|
||||||
|
since = datetime.now() - timedelta(hours=hours)
|
||||||
|
conditions = ["COALESCE(published_at, created_at) >= :since"]
|
||||||
|
params = {"since": since, "limit": limit}
|
||||||
|
if status:
|
||||||
|
conditions.append("status = :status")
|
||||||
|
params["status"] = status
|
||||||
|
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id, title, source, url, published_at, status, catalyst_id, error, created_at, analyzed_at "
|
||||||
|
"FROM news_items WHERE "
|
||||||
|
+ " AND ".join(conditions)
|
||||||
|
+ " ORDER BY COALESCE(published_at, created_at) DESC, id DESC LIMIT :limit"
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
return [dict(row._mapping) for row in result.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
async def build_theme_catalyst_scores(hours: int = 72, limit: int = 20) -> list[ThemeCatalystScore]:
|
async def build_theme_catalyst_scores(hours: int = 72, limit: int = 20) -> list[ThemeCatalystScore]:
|
||||||
since = datetime.now() - timedelta(hours=hours)
|
since = datetime.now() - timedelta(hours=hours)
|
||||||
async with get_db() as db:
|
async with get_db() as db:
|
||||||
|
|||||||
@ -31,9 +31,6 @@ class Settings(BaseSettings):
|
|||||||
top_sector_count: int = 5 # 关注板块数量
|
top_sector_count: int = 5 # 关注板块数量
|
||||||
top_stock_count: int = 6 # 最终推荐输出上限
|
top_stock_count: int = 6 # 最终推荐输出上限
|
||||||
candidate_pool_limit: int = 90 # 多路召回后的候选池上限
|
candidate_pool_limit: int = 90 # 多路召回后的候选池上限
|
||||||
llm_prefilter_limit: int = 24 # LLM 初筛保留数量
|
|
||||||
llm_prefilter_max_concurrent: int = 6
|
|
||||||
llm_final_limit: int = 10 # LLM 深裁决池上限
|
|
||||||
actionable_limit: int = 3 # 最多可操作标的
|
actionable_limit: int = 3 # 最多可操作标的
|
||||||
watch_limit: int = 5 # 最多重点关注标的
|
watch_limit: int = 5 # 最多重点关注标的
|
||||||
min_turnover_rate: float = 2.0 # 最小换手率 %
|
min_turnover_rate: float = 2.0 # 最小换手率 %
|
||||||
@ -54,6 +51,16 @@ class Settings(BaseSettings):
|
|||||||
pullback_max_shrink_ratio: float = 0.85 # 回踩型最大缩量比
|
pullback_max_shrink_ratio: float = 0.85 # 回踩型最大缩量比
|
||||||
consolidation_max_range_pct: float = 8.0 # 启动型最大整理振幅 %
|
consolidation_max_range_pct: float = 8.0 # 启动型最大整理振幅 %
|
||||||
|
|
||||||
|
# 新闻/政策催化采集
|
||||||
|
news_collection_enabled: bool = True
|
||||||
|
news_tushare_sources: str = "sina,eastmoney,10jqka,wallstreetcn"
|
||||||
|
news_tushare_sources_per_run: int = 1
|
||||||
|
news_rss_sources: str = "" # name|url,name|url
|
||||||
|
news_fetch_lookback_hours: int = 24
|
||||||
|
news_fetch_limit_per_source: int = 30
|
||||||
|
news_analyze_limit_per_run: int = 50
|
||||||
|
news_min_title_length: int = 8
|
||||||
|
|
||||||
# LLM (DeepSeek)
|
# LLM (DeepSeek)
|
||||||
deepseek_api_key: str = ""
|
deepseek_api_key: str = ""
|
||||||
deepseek_base_url: str = "https://api.deepseek.com/v1"
|
deepseek_base_url: str = "https://api.deepseek.com/v1"
|
||||||
|
|||||||
54
backend/app/data/code_utils.py
Normal file
54
backend/app/data/code_utils.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"""股票代码规范化工具。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
SUPPORTED_MARKETS = {"SH", "SZ", "BJ"}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_ts_code(value: str | None) -> str:
|
||||||
|
"""把常见 A 股代码输入规范成 Tushare 格式。
|
||||||
|
|
||||||
|
支持:
|
||||||
|
- 603779 -> 603779.SH
|
||||||
|
- 300750 -> 300750.SZ
|
||||||
|
- 430047 -> 430047.BJ
|
||||||
|
- sh600519 / sz000001 / bj430047
|
||||||
|
- 600519.sh -> 600519.SH
|
||||||
|
"""
|
||||||
|
text = str(value or "").strip().upper()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
compact_match = re.fullmatch(r"(SH|SZ|BJ)(\d{6})", text)
|
||||||
|
if compact_match:
|
||||||
|
market, code = compact_match.groups()
|
||||||
|
return f"{code}.{market}"
|
||||||
|
|
||||||
|
dotted_match = re.fullmatch(r"(\d{6})\.(SH|SZ|BJ)", text)
|
||||||
|
if dotted_match:
|
||||||
|
code, market = dotted_match.groups()
|
||||||
|
return f"{code}.{market}"
|
||||||
|
|
||||||
|
if re.fullmatch(r"\d{6}", text):
|
||||||
|
if text.startswith(("6", "9")):
|
||||||
|
return f"{text}.SH"
|
||||||
|
if text.startswith(("0", "2", "3")):
|
||||||
|
return f"{text}.SZ"
|
||||||
|
if text.startswith(("4", "8")):
|
||||||
|
return f"{text}.BJ"
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def split_ts_code(value: str | None) -> tuple[str, str]:
|
||||||
|
"""返回 (code, market),无法识别时抛出带上下文的 ValueError。"""
|
||||||
|
normalized = normalize_ts_code(value)
|
||||||
|
if "." not in normalized:
|
||||||
|
raise ValueError(f"无效股票代码格式: {value!r}")
|
||||||
|
|
||||||
|
code, market = normalized.split(".", 1)
|
||||||
|
if not re.fullmatch(r"\d{6}", code) or market not in SUPPORTED_MARKETS:
|
||||||
|
raise ValueError(f"无效股票代码格式: {value!r}")
|
||||||
|
return code, market
|
||||||
@ -17,6 +17,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from app.data.cache import cache
|
from app.data.cache import cache
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.data.code_utils import split_ts_code
|
||||||
from app.db.error_logger import log_error
|
from app.db.error_logger import log_error
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -57,8 +58,8 @@ def _mark_rows_status(rows: list[dict], status: str, detail: str) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def _ts_code_to_eastmoney(ts_code: str) -> str:
|
def _ts_code_to_eastmoney(ts_code: str) -> str:
|
||||||
"""600519.SH -> 1.600519 (上海=1, 深圳=0)"""
|
"""600519.SH -> 1.600519 (上海=1, 深圳/北交所=0)"""
|
||||||
code, market = ts_code.split(".")
|
code, market = split_ts_code(ts_code)
|
||||||
prefix = "1" if market == "SH" else "0"
|
prefix = "1" if market == "SH" else "0"
|
||||||
return f"{prefix}.{code}"
|
return f"{prefix}.{code}"
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import httpx
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from app.data.cache import cache
|
from app.data.cache import cache
|
||||||
|
from app.data.code_utils import split_ts_code
|
||||||
from app.data.models import StockQuote
|
from app.data.models import StockQuote
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -29,7 +30,7 @@ HEADERS = {
|
|||||||
|
|
||||||
|
|
||||||
def _ts_code_to_sina(ts_code: str) -> str:
|
def _ts_code_to_sina(ts_code: str) -> str:
|
||||||
code, market = ts_code.split(".")
|
code, market = split_ts_code(ts_code)
|
||||||
return f"{market.lower()}{code}"
|
return f"{market.lower()}{code}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import logging
|
|||||||
import httpx
|
import httpx
|
||||||
from app.data.cache import cache
|
from app.data.cache import cache
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.data.code_utils import normalize_ts_code, split_ts_code
|
||||||
from app.data.models import StockQuote
|
from app.data.models import StockQuote
|
||||||
from app.db.error_logger import log_error
|
from app.db.error_logger import log_error
|
||||||
|
|
||||||
@ -22,7 +23,7 @@ HEADERS = {
|
|||||||
|
|
||||||
def _ts_code_to_tencent(ts_code: str) -> str:
|
def _ts_code_to_tencent(ts_code: str) -> str:
|
||||||
"""600519.SH -> sh600519"""
|
"""600519.SH -> sh600519"""
|
||||||
code, market = ts_code.split(".")
|
code, market = split_ts_code(ts_code)
|
||||||
return f"{market.lower()}{code}"
|
return f"{market.lower()}{code}"
|
||||||
|
|
||||||
|
|
||||||
@ -67,6 +68,7 @@ def _parse_tencent_response(raw: str) -> dict | None:
|
|||||||
|
|
||||||
async def get_realtime_quote(ts_code: str) -> StockQuote | None:
|
async def get_realtime_quote(ts_code: str) -> StockQuote | None:
|
||||||
"""获取单只股票实时行情"""
|
"""获取单只股票实时行情"""
|
||||||
|
ts_code = normalize_ts_code(ts_code)
|
||||||
cache_key = f"rt_quote:{ts_code}"
|
cache_key = f"rt_quote:{ts_code}"
|
||||||
cached = cache.get(cache_key)
|
cached = cache.get(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
@ -118,6 +120,7 @@ async def get_realtime_quotes_batch(ts_codes: list[str]) -> dict[str, StockQuote
|
|||||||
"""批量获取实时行情(腾讯支持逗号拼接多只)"""
|
"""批量获取实时行情(腾讯支持逗号拼接多只)"""
|
||||||
results = {}
|
results = {}
|
||||||
uncached = []
|
uncached = []
|
||||||
|
ts_codes = [normalize_ts_code(code) for code in ts_codes if normalize_ts_code(code)]
|
||||||
|
|
||||||
for code in ts_codes:
|
for code in ts_codes:
|
||||||
cached = cache.get(f"rt_quote:{code}")
|
cached = cache.get(f"rt_quote:{code}")
|
||||||
@ -191,6 +194,7 @@ async def get_index_realtime(index_codes: list[str] = None) -> dict[str, dict]:
|
|||||||
"""
|
"""
|
||||||
if not index_codes:
|
if not index_codes:
|
||||||
index_codes = ["000001.SH", "399001.SZ", "399006.SZ"]
|
index_codes = ["000001.SH", "399001.SZ", "399006.SZ"]
|
||||||
|
index_codes = [normalize_ts_code(code) for code in index_codes if normalize_ts_code(code)]
|
||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
symbols = ",".join(_ts_code_to_tencent(c) for c in index_codes)
|
symbols = ",".join(_ts_code_to_tencent(c) for c in index_codes)
|
||||||
|
|||||||
@ -265,5 +265,43 @@ class TushareClient:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── 新闻快讯 ──
|
||||||
|
|
||||||
|
def get_news(
|
||||||
|
self,
|
||||||
|
source: str,
|
||||||
|
start_time: datetime,
|
||||||
|
end_time: datetime,
|
||||||
|
limit: int = 30,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""获取 Tushare 新闻快讯。
|
||||||
|
|
||||||
|
Tushare news 接口不同账号权限不一,失败时返回空 DataFrame,
|
||||||
|
不阻断其他新闻源或推荐流程。该接口常见限频为 1 次/分钟或
|
||||||
|
2 次/小时,因此这里不复用通用重试逻辑,避免失败重试继续消耗配额。
|
||||||
|
"""
|
||||||
|
cache_key = f"news:{source}:{start_time:%Y%m%d%H}:{end_time:%Y%m%d%H}:{limit}"
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._ensure_init()
|
||||||
|
self._rate_limit()
|
||||||
|
result = self.pro.news(
|
||||||
|
src=source,
|
||||||
|
start_date=start_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
end_date=end_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
if result is None or result.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
cache.set(cache_key, result, 600)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Tushare 新闻请求失败 source=%s: %s", source, e)
|
||||||
|
log_error_background("tushare_news", f"Tushare 新闻请求失败 source={source}: {e}")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
|
||||||
tushare_client = TushareClient()
|
tushare_client = TushareClient()
|
||||||
|
|||||||
@ -98,6 +98,8 @@ async def init_db():
|
|||||||
"ALTER TABLE strategy_configs ADD COLUMN effective_from DATETIME DEFAULT CURRENT_TIMESTAMP",
|
"ALTER TABLE strategy_configs ADD COLUMN effective_from DATETIME DEFAULT CURRENT_TIMESTAMP",
|
||||||
"ALTER TABLE prompt_configs ADD COLUMN evidence_json TEXT DEFAULT '{}'",
|
"ALTER TABLE prompt_configs ADD COLUMN evidence_json TEXT DEFAULT '{}'",
|
||||||
"ALTER TABLE strategy_config_changes ADD COLUMN prompt_key TEXT DEFAULT ''",
|
"ALTER TABLE strategy_config_changes ADD COLUMN prompt_key TEXT DEFAULT ''",
|
||||||
|
"ALTER TABLE news_items ADD COLUMN summary 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 ''",
|
||||||
]:
|
]:
|
||||||
try:
|
try:
|
||||||
@ -107,6 +109,16 @@ async def init_db():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # 列已存在,忽略
|
pass # 列已存在,忽略
|
||||||
|
|
||||||
|
for index_sql in [
|
||||||
|
"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_catalysts_source_url ON catalysts(source, url)",
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
await conn.execute(__import__("sqlalchemy").text(index_sql))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
__import__("sqlalchemy").text(
|
__import__("sqlalchemy").text(
|
||||||
|
|||||||
@ -240,6 +240,23 @@ strategy_config_changes_table = Table(
|
|||||||
Column("applied_at", DateTime),
|
Column("applied_at", DateTime),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
news_items_table = Table(
|
||||||
|
"news_items", metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("title", Text, nullable=False),
|
||||||
|
Column("content", Text, default=""),
|
||||||
|
Column("summary", Text, default=""),
|
||||||
|
Column("source", Text, default=""),
|
||||||
|
Column("url", Text, default=""),
|
||||||
|
Column("published_at", DateTime),
|
||||||
|
Column("dedup_key", Text, nullable=False, unique=True),
|
||||||
|
Column("status", Text, default="pending"), # pending / analyzed / skipped / failed
|
||||||
|
Column("catalyst_id", Integer),
|
||||||
|
Column("error", Text, default=""),
|
||||||
|
Column("created_at", DateTime, server_default=func.now()),
|
||||||
|
Column("analyzed_at", DateTime),
|
||||||
|
)
|
||||||
|
|
||||||
catalysts_table = Table(
|
catalysts_table = Table(
|
||||||
"catalysts", metadata,
|
"catalysts", metadata,
|
||||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
|||||||
@ -577,7 +577,7 @@ async def get_recommendation_history(days: int = 7) -> list[dict]:
|
|||||||
" ) lt ON t.id = lt.max_id"
|
" ) lt ON t.id = lt.max_id"
|
||||||
") latest_t ON latest_t.recommendation_id = r.id "
|
") latest_t ON latest_t.recommendation_id = r.id "
|
||||||
"WHERE r.created_at >= :start "
|
"WHERE r.created_at >= :start "
|
||||||
"AND (r.action_plan IN ('可操作', '重点关注') OR COALESCE(r.llm_score, 0) >= 6 OR r.score >= 56) "
|
"AND (r.action_plan IN ('可操作', '重点关注') OR r.score >= 56) "
|
||||||
"AND ("
|
"AND ("
|
||||||
" COALESCE(r.recall_tags, '[]') LIKE '%hot_theme_core%' "
|
" COALESCE(r.recall_tags, '[]') LIKE '%hot_theme_core%' "
|
||||||
" OR COALESCE(r.recall_tags, '[]') LIKE '%theme_leader%' "
|
" OR COALESCE(r.recall_tags, '[]') LIKE '%theme_leader%' "
|
||||||
@ -810,7 +810,6 @@ async def _save_to_db(result: dict):
|
|||||||
rec for rec in result.get("recommendations", [])
|
rec for rec in result.get("recommendations", [])
|
||||||
if (
|
if (
|
||||||
rec.action_plan in {"可操作", "重点关注"}
|
rec.action_plan in {"可操作", "重点关注"}
|
||||||
or (rec.llm_score is not None and rec.llm_score >= 6)
|
|
||||||
or rec.score >= 56
|
or rec.score >= 56
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@ -916,7 +915,7 @@ async def _load_today_from_db() -> dict:
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
text("SELECT * FROM recommendations "
|
text("SELECT * FROM recommendations "
|
||||||
"WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) "
|
"WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) "
|
||||||
"AND (action_plan IN ('可操作', '重点关注') OR COALESCE(llm_score, 0) >= 6 OR score >= 56) "
|
"AND (action_plan IN ('可操作', '重点关注') OR score >= 56) "
|
||||||
"AND ("
|
"AND ("
|
||||||
" COALESCE(recall_tags, '[]') LIKE '%hot_theme_core%' "
|
" COALESCE(recall_tags, '[]') LIKE '%hot_theme_core%' "
|
||||||
" OR COALESCE(recall_tags, '[]') LIKE '%theme_leader%' "
|
" OR COALESCE(recall_tags, '[]') LIKE '%theme_leader%' "
|
||||||
|
|||||||
@ -39,6 +39,26 @@ async def _run_scan(session_name: str):
|
|||||||
await log_error("scheduler", f"定时扫描失败 ({session_name}): {e}", detail=traceback.format_exc())
|
await log_error("scheduler", f"定时扫描失败 ({session_name}): {e}", detail=traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_news_refresh(session_name: str = "scheduled"):
|
||||||
|
"""后台采集新闻并更新主题催化分。"""
|
||||||
|
logger.info("=== 新闻催化刷新: %s ===", session_name)
|
||||||
|
try:
|
||||||
|
from app.news.pipeline import refresh_news_catalysts
|
||||||
|
|
||||||
|
result = await refresh_news_catalysts()
|
||||||
|
await broadcast_update({
|
||||||
|
"type": "news_catalysts_ready",
|
||||||
|
"session": session_name,
|
||||||
|
"inserted": result.get("inserted", 0),
|
||||||
|
"analyzed": result.get("analyzed", 0),
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"新闻催化刷新失败 ({session_name}): {e}")
|
||||||
|
from app.db.error_logger import log_error
|
||||||
|
await log_error("scheduler", f"新闻催化刷新失败 ({session_name}): {e}", detail=traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
async def _run_watchlist_analysis():
|
async def _run_watchlist_analysis():
|
||||||
"""收盘后自动分析所有用户自选股。"""
|
"""收盘后自动分析所有用户自选股。"""
|
||||||
logger.info("=== 开始自选股定时分析 ===")
|
logger.info("=== 开始自选股定时分析 ===")
|
||||||
@ -77,6 +97,22 @@ async def _run_strategy_iteration():
|
|||||||
def setup_scheduler():
|
def setup_scheduler():
|
||||||
"""配置所有定时任务(交易日时间)"""
|
"""配置所有定时任务(交易日时间)"""
|
||||||
|
|
||||||
|
news_schedule = [
|
||||||
|
("news_pre_market", 8, 50, "pre_market"),
|
||||||
|
("news_morning", 10, 5, "morning"),
|
||||||
|
("news_noon", 12, 45, "noon"),
|
||||||
|
("news_afternoon", 13, 55, "afternoon"),
|
||||||
|
("news_post_market", 15, 40, "post_market"),
|
||||||
|
]
|
||||||
|
for job_id, hour, minute, session_name in news_schedule:
|
||||||
|
scheduler.add_job(
|
||||||
|
_run_news_refresh,
|
||||||
|
CronTrigger(hour=hour, minute=minute, day_of_week="mon-fri"),
|
||||||
|
args=[session_name],
|
||||||
|
id=job_id,
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
# 盘前准备 09:00 - 计算前一日市场温度和板块数据
|
# 盘前准备 09:00 - 计算前一日市场温度和板块数据
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
_run_scan, CronTrigger(hour=9, minute=0, day_of_week="mon-fri"),
|
_run_scan, CronTrigger(hour=9, minute=0, day_of_week="mon-fri"),
|
||||||
|
|||||||
@ -3,10 +3,10 @@
|
|||||||
三阶段管道:
|
三阶段管道:
|
||||||
Step 1: 主线定位 — 把实时板块/快照板块归一成系统 MarketTheme
|
Step 1: 主线定位 — 把实时板块/快照板块归一成系统 MarketTheme
|
||||||
Step 2: 主题内选股 — 从主线主题成分、领涨股和实时异动中召回候选
|
Step 2: 主题内选股 — 从主线主题成分、领涨股和实时异动中召回候选
|
||||||
Step 3: 深度分析 — 资金顺势 + 供需 + 价格行为 + 趋势 + LLM
|
Step 3: 规则定价 — 催化 + 主题资金 + 个股资金 + 情绪角色 + 入场节奏
|
||||||
|
|
||||||
评分公式:资金顺势 + 供需关系 + 价格行为 + 趋势。
|
评分公式:市场热点/新闻催化 + 主线资金 + 个股资金 + 情绪地位 + 时机。
|
||||||
资金流和主题地位进入主评分,RSI/MACD 只作为节奏与风险参考。
|
技术指标只作为入场节奏与风控参考,不替代热点与资金主线。
|
||||||
|
|
||||||
风险乘数:惩罚取最大而非叠加(防过度惩罚),奖励可叠加。
|
风险乘数:惩罚取最大而非叠加(防过度惩罚),奖励可叠加。
|
||||||
|
|
||||||
@ -19,7 +19,6 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
@ -144,29 +143,16 @@ async def run_screening(trade_date: str = None) -> dict:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"注入实时价格失败,使用 Tushare 收盘价: {e}")
|
logger.warning(f"注入实时价格失败,使用 Tushare 收盘价: {e}")
|
||||||
|
|
||||||
# ── Step 3: 规则边界 + LLM 两阶段裁决 ──
|
# ── Step 3: 规则评分与交易计划 ──
|
||||||
logger.info("=== Step 3: 规则边界 + LLM 两阶段裁决 ===")
|
logger.info("=== Step 3: 规则评分与交易计划 ===")
|
||||||
recommendations = await _build_recommendations(
|
recommendations = await _build_recommendations(
|
||||||
candidates, market_temp, hot_sectors, market_temp_score, intraday, strategy_profile,
|
candidates, market_temp, hot_sectors, market_temp_score, intraday, strategy_profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
if settings.deepseek_api_key:
|
recommendations = [
|
||||||
recommendations = [
|
r for r in recommendations
|
||||||
r for r in recommendations
|
if _is_main_theme_recommendation(r) and r.score >= strategy_profile.min_score
|
||||||
if (
|
]
|
||||||
_is_main_theme_recommendation(r)
|
|
||||||
and (
|
|
||||||
r.action_plan in {"可操作", "重点关注"}
|
|
||||||
or (r.llm_score is not None and r.llm_score >= 6)
|
|
||||||
or r.score >= max(strategy_profile.min_score - 4, 56)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
recommendations = [
|
|
||||||
r for r in recommendations
|
|
||||||
if _is_main_theme_recommendation(r) and r.score >= strategy_profile.min_score
|
|
||||||
]
|
|
||||||
|
|
||||||
recommendations = _finalize_battle_plan(
|
recommendations = _finalize_battle_plan(
|
||||||
recommendations=recommendations,
|
recommendations=recommendations,
|
||||||
@ -388,7 +374,7 @@ async def _build_candidate_pool(
|
|||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""多路召回候选池。
|
"""多路召回候选池。
|
||||||
|
|
||||||
目标是提高召回率,再交给 LLM 做资源分配与最终裁决。
|
目标是提高主线、资金、形态多路召回率,最终由规则评分统一排序。
|
||||||
"""
|
"""
|
||||||
merged: dict[str, dict] = {}
|
merged: dict[str, dict] = {}
|
||||||
|
|
||||||
@ -610,21 +596,16 @@ async def _build_recommendations(
|
|||||||
intraday: bool = False,
|
intraday: bool = False,
|
||||||
strategy_profile=None,
|
strategy_profile=None,
|
||||||
) -> list[Recommendation]:
|
) -> list[Recommendation]:
|
||||||
"""Step 3: 规则边界建模 + LLM 两阶段裁决。"""
|
"""Step 3: 规则边界建模、评分与交易计划生成。"""
|
||||||
from app.data.tushare_client import tushare_client
|
from app.data.tushare_client import tushare_client
|
||||||
from app.analysis.technical import add_all_indicators
|
from app.analysis.technical import add_all_indicators
|
||||||
from app.analysis.breakout_signals import (
|
from app.analysis.breakout_signals import (
|
||||||
classify_entry_signal,
|
classify_entry_signal,
|
||||||
score_supply_demand,
|
score_supply_demand,
|
||||||
analyze_volume_pattern,
|
|
||||||
EntrySignal,
|
EntrySignal,
|
||||||
)
|
)
|
||||||
from app.analysis.signals import generate_signals
|
from app.analysis.signals import generate_signals
|
||||||
from app.analysis.capital_flow import _score_valuation
|
from app.analysis.capital_flow import _score_valuation
|
||||||
from app.llm.batch_screener import (
|
|
||||||
analyze_candidates_individually,
|
|
||||||
prefilter_candidates_individually,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 名称和行业映射
|
# 名称和行业映射
|
||||||
stock_basic = tushare_client.get_stock_basic()
|
stock_basic = tushare_client.get_stock_basic()
|
||||||
@ -636,7 +617,6 @@ async def _build_recommendations(
|
|||||||
industry_map[row["ts_code"]] = row.get("industry", "")
|
industry_map[row["ts_code"]] = row.get("industry", "")
|
||||||
|
|
||||||
recommendations = []
|
recommendations = []
|
||||||
llm_candidates = []
|
|
||||||
total = len(candidates)
|
total = len(candidates)
|
||||||
signal_counts = {"breakout": 0, "breakout_confirm": 0, "pullback": 0, "launch": 0, "reversal": 0, "none": 0}
|
signal_counts = {"breakout": 0, "breakout_confirm": 0, "pullback": 0, "launch": 0, "reversal": 0, "none": 0}
|
||||||
score_weights = strategy_profile.score_weights if strategy_profile else {
|
score_weights = strategy_profile.score_weights if strategy_profile else {
|
||||||
@ -914,7 +894,6 @@ async def _build_recommendations(
|
|||||||
stock["entry_signal_type"] = effective_signal_name
|
stock["entry_signal_type"] = effective_signal_name
|
||||||
reasons = _generate_reasons(stock, entry_signal, tech_signal, df, intraday)
|
reasons = _generate_reasons(stock, entry_signal, tech_signal, df, intraday)
|
||||||
risk_note = _generate_risk_note(market_temp, tech_signal, stock)
|
risk_note = _generate_risk_note(market_temp, tech_signal, stock)
|
||||||
vol_pattern = analyze_volume_pattern(df)
|
|
||||||
entry_timing = _generate_entry_timing(effective_signal_name, intraday)
|
entry_timing = _generate_entry_timing(effective_signal_name, intraday)
|
||||||
trade_plan = _build_trade_plan(
|
trade_plan = _build_trade_plan(
|
||||||
signal_type=effective_signal_name,
|
signal_type=effective_signal_name,
|
||||||
@ -1001,55 +980,8 @@ async def _build_recommendations(
|
|||||||
)
|
)
|
||||||
recommendations.append(rec)
|
recommendations.append(rec)
|
||||||
|
|
||||||
# 收集 LLM 分析所需的候选摘要(不含 signal_type,让 LLM 独立判断)
|
|
||||||
llm_candidate = {
|
|
||||||
"ts_code": ts_code,
|
|
||||||
"name": name,
|
|
||||||
"sector": sector,
|
|
||||||
"quant_score": round(final_score, 1),
|
|
||||||
"position_score": round(position_score, 1),
|
|
||||||
"current_price": stock.get("price") or float(df.iloc[-1]["close"]),
|
|
||||||
"kline_summary": _summarize_for_llm(df, entry_signal, tech_signal),
|
|
||||||
"capital_flow_summary": (
|
|
||||||
f"主力净流入{stock.get('main_net_inflow', 0):.0f}万, "
|
|
||||||
f"占比{stock.get('inflow_ratio', 0):.1f}%"
|
|
||||||
),
|
|
||||||
"recall_tags": stock.get("recall_tags", []),
|
|
||||||
"sector_stage": sector_stage,
|
|
||||||
"hot_theme_matched": bool(hot_theme_match),
|
|
||||||
"hot_theme_name": hot_theme_match.sector_name if hot_theme_match else "",
|
|
||||||
"hot_theme_aliases": hot_theme_match.theme_aliases if hot_theme_match else [],
|
|
||||||
"stock_role_hint": stock.get("stock_role_hint", "待判断"),
|
|
||||||
"entry_signal_type": effective_signal_name,
|
|
||||||
"entry_signal_score": round(entry_signal.get("signal_score", 0), 1),
|
|
||||||
"flow_momentum_score": round(flow_momentum_score, 1),
|
|
||||||
"signal_matches_profile": signal_matches_profile,
|
|
||||||
"risk_tags": risk_tags,
|
|
||||||
"focus_points": _build_focus_points(stock, entry_signal, tech_signal, vol_pattern, sector_stage),
|
|
||||||
}
|
|
||||||
|
|
||||||
if intraday:
|
|
||||||
try:
|
|
||||||
from app.data.eastmoney_client import get_min_kline, analyze_intraday_volume_distribution
|
|
||||||
min_df = await get_min_kline(ts_code, period="5", count=48)
|
|
||||||
if not min_df.empty:
|
|
||||||
vol_dist = analyze_intraday_volume_distribution(min_df)
|
|
||||||
llm_candidate["intraday_volume"] = (
|
|
||||||
f"上午量占比{vol_dist['morning_volume_ratio']}%, "
|
|
||||||
f"下午{vol_dist['afternoon_volume_ratio']}%, "
|
|
||||||
f"开盘30分{vol_dist['opening_strength']}%, "
|
|
||||||
f"尾盘30分{vol_dist['closing_strength']}%, "
|
|
||||||
f"趋势={vol_dist['volume_trend']}"
|
|
||||||
)
|
|
||||||
if vol_dist["key_periods"]:
|
|
||||||
llm_candidate["intraday_volume"] += f", 放量时段: {'; '.join(vol_dist['key_periods'])}"
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"分时量能数据获取失败 {ts_code}: {e}")
|
|
||||||
|
|
||||||
llm_candidates.append(llm_candidate)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"深度分析 {ts_code} 失败: {e}")
|
logger.debug(f"规则分析 {ts_code} 失败: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -1061,143 +993,6 @@ async def _build_recommendations(
|
|||||||
)
|
)
|
||||||
|
|
||||||
recommendations.sort(key=lambda rec: rec.score, reverse=True)
|
recommendations.sort(key=lambda rec: rec.score, reverse=True)
|
||||||
|
|
||||||
if settings.deepseek_api_key and llm_candidates:
|
|
||||||
try:
|
|
||||||
market_summary = (
|
|
||||||
f"市场温度: {market_temp.temperature}/100, "
|
|
||||||
f"涨跌比: {market_temp.up_count}涨/{market_temp.down_count}跌, "
|
|
||||||
f"涨停: {market_temp.limit_up_count}家; "
|
|
||||||
f"今日主线主题: "
|
|
||||||
+ " / ".join(
|
|
||||||
f"{s.sector_name}[{' / '.join((s.theme_aliases or [])[:3])}]"
|
|
||||||
f"({(s.realtime_pct_change if s.realtime_pct_change is not None else s.pct_change):+.2f}%)"
|
|
||||||
for s in hot_sectors[:5]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
llm_candidates.sort(key=lambda c: c["quant_score"], reverse=True)
|
|
||||||
prefilter_pool = llm_candidates[: settings.llm_prefilter_limit]
|
|
||||||
prefilter_results = await prefilter_candidates_individually(
|
|
||||||
prefilter_pool,
|
|
||||||
market_summary,
|
|
||||||
max_concurrent=settings.llm_prefilter_max_concurrent,
|
|
||||||
)
|
|
||||||
|
|
||||||
prioritized = []
|
|
||||||
for item in prefilter_pool:
|
|
||||||
pre = prefilter_results.get(item["ts_code"], {})
|
|
||||||
item["prefilter_decision"] = pre.get("decision", "watch")
|
|
||||||
item["prefilter_confidence"] = pre.get("confidence", 5)
|
|
||||||
item["prefilter_reason"] = pre.get("reason", "")
|
|
||||||
item["prefilter_focus_points"] = pre.get("focus_points", [])
|
|
||||||
if item["prefilter_decision"] == "priority":
|
|
||||||
rank_bonus = 16
|
|
||||||
elif item["prefilter_decision"] == "watch":
|
|
||||||
rank_bonus = 6
|
|
||||||
else:
|
|
||||||
rank_bonus = -12
|
|
||||||
item["deep_rank"] = round(item["quant_score"] + rank_bonus + item["prefilter_confidence"] * 1.5, 1)
|
|
||||||
if item["prefilter_decision"] != "ignore":
|
|
||||||
prioritized.append(item)
|
|
||||||
|
|
||||||
if not prioritized:
|
|
||||||
prioritized = prefilter_pool[: min(8, len(prefilter_pool))]
|
|
||||||
|
|
||||||
prioritized.sort(key=lambda c: c.get("deep_rank", c["quant_score"]), reverse=True)
|
|
||||||
llm_top = prioritized[: settings.llm_final_limit]
|
|
||||||
llm_results = await analyze_candidates_individually(llm_top, market_summary)
|
|
||||||
|
|
||||||
for rec in recommendations:
|
|
||||||
pre_item = next((item for item in prefilter_pool if item["ts_code"] == rec.ts_code), None)
|
|
||||||
if pre_item:
|
|
||||||
rec.prefilter_decision = pre_item.get("prefilter_decision", "")
|
|
||||||
rec.prefilter_reason = pre_item.get("prefilter_reason", "")
|
|
||||||
rec.focus_points = pre_item.get("prefilter_focus_points", [])
|
|
||||||
|
|
||||||
llm_data = llm_results.get(rec.ts_code)
|
|
||||||
if llm_data:
|
|
||||||
rec.llm_analysis = llm_data.get("analysis", "")
|
|
||||||
rec.llm_score = float(llm_data.get("conviction", 0) or 0)
|
|
||||||
|
|
||||||
verdict = llm_data.get("verdict", "watch")
|
|
||||||
action_plan = llm_data.get("action_plan", "")
|
|
||||||
conviction = float(llm_data.get("conviction", 6) or 6)
|
|
||||||
ai_score = conviction * 10
|
|
||||||
|
|
||||||
if verdict == "execute":
|
|
||||||
rec.score = round(rec.score * 0.4 + ai_score * 0.6 + 4, 1)
|
|
||||||
elif verdict == "watch":
|
|
||||||
rec.score = round(rec.score * 0.5 + ai_score * 0.5 - 2, 1)
|
|
||||||
else: # skip
|
|
||||||
rec.score = round(rec.score * 0.45 + ai_score * 0.35 - 18, 1)
|
|
||||||
|
|
||||||
if verdict == "skip":
|
|
||||||
rec.signal = "HOLD"
|
|
||||||
rec.action_plan = "观察"
|
|
||||||
rec.lifecycle_status = "candidate"
|
|
||||||
if not rec.risk_note:
|
|
||||||
rec.risk_note = llm_data.get("risk_flag", "") or rec.risk_note
|
|
||||||
else:
|
|
||||||
if action_plan in {"可操作", "重点关注", "观察"}:
|
|
||||||
rec.action_plan = action_plan
|
|
||||||
elif verdict == "execute":
|
|
||||||
rec.action_plan = "可操作"
|
|
||||||
else:
|
|
||||||
rec.action_plan = "重点关注"
|
|
||||||
|
|
||||||
rec.signal = "BUY" if verdict == "execute" else "HOLD"
|
|
||||||
if rec.action_plan == "可操作":
|
|
||||||
rec.lifecycle_status = "actionable"
|
|
||||||
elif rec.action_plan == "重点关注":
|
|
||||||
rec.lifecycle_status = "candidate"
|
|
||||||
|
|
||||||
if llm_data.get("timing"):
|
|
||||||
rec.entry_timing = llm_data["timing"]
|
|
||||||
|
|
||||||
if llm_data.get("trigger_condition"):
|
|
||||||
rec.trigger_condition = llm_data["trigger_condition"]
|
|
||||||
if llm_data.get("invalidation_condition"):
|
|
||||||
rec.invalidation_condition = llm_data["invalidation_condition"]
|
|
||||||
if llm_data.get("position_pct") is not None:
|
|
||||||
rec.suggested_position_pct = float(llm_data["position_pct"] or 0)
|
|
||||||
if llm_data.get("risk_flag"):
|
|
||||||
rec.risk_note = llm_data["risk_flag"]
|
|
||||||
|
|
||||||
rec.level = _score_to_level(rec.score)
|
|
||||||
_apply_llm_trace(
|
|
||||||
rec,
|
|
||||||
verdict=verdict,
|
|
||||||
action_plan=rec.action_plan,
|
|
||||||
conviction=conviction,
|
|
||||||
reason=llm_data.get("analysis", "") or llm_data.get("risk_flag", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 用 LLM 给出的价格替代结构化规则价格
|
|
||||||
if llm_data.get("entry_price"):
|
|
||||||
rec.entry_price = llm_data["entry_price"]
|
|
||||||
if llm_data.get("target_price"):
|
|
||||||
rec.target_price = llm_data["target_price"]
|
|
||||||
if llm_data.get("stop_loss"):
|
|
||||||
rec.stop_loss = llm_data["stop_loss"]
|
|
||||||
|
|
||||||
recommendations = [
|
|
||||||
rec for rec in recommendations
|
|
||||||
if not (
|
|
||||||
rec.llm_score is not None
|
|
||||||
and rec.llm_score <= 4
|
|
||||||
and rec.action_plan == "观察"
|
|
||||||
and rec.score < max(strategy_profile.min_score - 6, 54)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
recommendations.sort(key=lambda r: r.score, reverse=True)
|
|
||||||
recommendations = recommendations[:settings.top_stock_count]
|
|
||||||
logger.info(f"LLM 两阶段分析完成, 综合评分后保留 {len(recommendations)} 只")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"LLM 两阶段分析失败, 仅使用规则边界: {e}")
|
|
||||||
from app.db.error_logger import log_error
|
|
||||||
await log_error("screener", f"LLM 两阶段分析失败, 仅使用规则边界: {e}", detail=traceback.format_exc())
|
|
||||||
|
|
||||||
return recommendations
|
return recommendations
|
||||||
|
|
||||||
|
|
||||||
@ -2224,186 +2019,3 @@ def _build_trace_evidence(
|
|||||||
evidence.append("符合今日策略偏好的入场类型")
|
evidence.append("符合今日策略偏好的入场类型")
|
||||||
return evidence[:5]
|
return evidence[:5]
|
||||||
|
|
||||||
|
|
||||||
def _apply_llm_trace(
|
|
||||||
rec: Recommendation,
|
|
||||||
verdict: str,
|
|
||||||
action_plan: str,
|
|
||||||
conviction: float,
|
|
||||||
reason: str,
|
|
||||||
) -> None:
|
|
||||||
trace = dict(rec.decision_trace or {})
|
|
||||||
trace["llm_adjustment"] = {
|
|
||||||
"verdict": verdict,
|
|
||||||
"action_plan": action_plan,
|
|
||||||
"conviction": round(conviction, 1),
|
|
||||||
"reason": str(reason or "")[:180],
|
|
||||||
}
|
|
||||||
trace["action_plan"] = action_plan
|
|
||||||
if verdict == "execute":
|
|
||||||
trace["headline"] = f"AI确认可执行: {trace.get('headline', rec.name)}"
|
|
||||||
elif verdict == "skip":
|
|
||||||
trace["headline"] = f"AI降级观察: {trace.get('headline', rec.name)}"
|
|
||||||
rec.decision_trace = trace
|
|
||||||
|
|
||||||
|
|
||||||
def _build_focus_points(
|
|
||||||
stock: dict,
|
|
||||||
entry_signal: dict,
|
|
||||||
tech: TechnicalSignal | None,
|
|
||||||
vol_pattern: dict,
|
|
||||||
sector_stage: str,
|
|
||||||
) -> list[str]:
|
|
||||||
points: list[str] = []
|
|
||||||
signal_type = entry_signal.get("signal_type")
|
|
||||||
if signal_type and getattr(signal_type, "value", "none") != "none":
|
|
||||||
points.append(f"确认{signal_type.value}信号是否延续")
|
|
||||||
elif stock.get("entry_signal_type") == "flow_momentum":
|
|
||||||
points.append("确认主力流入和板块前排强度是否延续")
|
|
||||||
if stock.get("main_net_inflow", 0) > 0:
|
|
||||||
points.append("观察主力流入是否继续放大")
|
|
||||||
if vol_pattern.get("volume_trend"):
|
|
||||||
points.append(f"量能状态: {vol_pattern['volume_trend']}")
|
|
||||||
if tech and tech.support_price:
|
|
||||||
points.append(f"关键支撑 {tech.support_price}")
|
|
||||||
if sector_stage in ("late", "end"):
|
|
||||||
points.append("板块已偏后段,注意是否还有前排承接")
|
|
||||||
return points[:4]
|
|
||||||
|
|
||||||
|
|
||||||
def _summarize_for_llm(df, entry_signal: dict, tech_signal: TechnicalSignal | None) -> str:
|
|
||||||
"""生成 K 线分析结论供 LLM 判断(输出结论而非原始数据)"""
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
last = df.iloc[-1]
|
|
||||||
parts = []
|
|
||||||
|
|
||||||
# ── 趋势结论 ──
|
|
||||||
ma_fields = ["ma5", "ma10", "ma20", "ma60"]
|
|
||||||
ma_vals = {m: last.get(m) for m in ma_fields}
|
|
||||||
|
|
||||||
trend_desc = "趋势不明"
|
|
||||||
all_ma_valid = all(ma_vals.get(m) is not None and not pd.isna(ma_vals[m]) for m in ma_fields)
|
|
||||||
if all_ma_valid:
|
|
||||||
if ma_vals["ma5"] > ma_vals["ma10"] > ma_vals["ma20"] > ma_vals["ma60"]:
|
|
||||||
trend_desc = "强势多头排列(MA5>MA10>MA20>MA60)"
|
|
||||||
elif ma_vals["ma5"] > ma_vals["ma10"] > ma_vals["ma20"]:
|
|
||||||
trend_desc = "中短期多头(MA5>MA10>MA20)"
|
|
||||||
elif ma_vals["ma5"] > ma_vals["ma20"]:
|
|
||||||
trend_desc = "偏多(MA5在MA20上方)"
|
|
||||||
elif ma_vals["ma5"] < ma_vals["ma10"] < ma_vals["ma20"]:
|
|
||||||
trend_desc = "空头排列,趋势偏弱"
|
|
||||||
else:
|
|
||||||
trend_desc = "均线交织,趋势震荡"
|
|
||||||
|
|
||||||
# MA20 方向
|
|
||||||
if len(df) >= 5 and not pd.isna(last.get("ma20")) and not pd.isna(df.iloc[-5].get("ma20")):
|
|
||||||
ma20_now = last["ma20"]
|
|
||||||
ma20_5d = df.iloc[-5]["ma20"]
|
|
||||||
if ma20_5d > 0:
|
|
||||||
ma20_pct = (ma20_now - ma20_5d) / ma20_5d * 100
|
|
||||||
if ma20_pct > 2:
|
|
||||||
trend_desc += ",MA20快速上扬"
|
|
||||||
elif ma20_pct > 0:
|
|
||||||
trend_desc += ",MA20缓慢上行"
|
|
||||||
else:
|
|
||||||
trend_desc += ",MA20走平或下行"
|
|
||||||
parts.append(trend_desc)
|
|
||||||
|
|
||||||
# ── 量价结论 ──
|
|
||||||
if len(df) >= 10:
|
|
||||||
recent = df.tail(10)
|
|
||||||
up_days = recent[recent["pct_chg"] > 0]
|
|
||||||
down_days = recent[recent["pct_chg"] <= 0]
|
|
||||||
vol_conclusion = ""
|
|
||||||
if len(up_days) > 0 and len(down_days) > 0:
|
|
||||||
avg_up_vol = up_days["vol"].mean()
|
|
||||||
avg_down_vol = down_days["vol"].mean()
|
|
||||||
if avg_down_vol > 0:
|
|
||||||
ratio = avg_up_vol / avg_down_vol
|
|
||||||
if ratio > 1.5:
|
|
||||||
vol_conclusion = f"量价健康(上涨均量/下跌均量={ratio:.1f},需求主导)"
|
|
||||||
elif ratio > 1.0:
|
|
||||||
vol_conclusion = f"量价尚可(量比={ratio:.1f},需求略强)"
|
|
||||||
else:
|
|
||||||
vol_conclusion = f"量价偏弱(量比={ratio:.1f},供给主导)"
|
|
||||||
if not vol_conclusion:
|
|
||||||
vol_conclusion = "量价数据不足"
|
|
||||||
|
|
||||||
# 最近5日量能变化
|
|
||||||
recent_5 = df.tail(5)
|
|
||||||
vol_ma5 = recent_5["vol"].mean()
|
|
||||||
vol_ma10 = df.tail(10)["vol"].mean()
|
|
||||||
if vol_ma10 > 0:
|
|
||||||
vol_ratio = vol_ma5 / vol_ma10
|
|
||||||
if vol_ratio > 1.5:
|
|
||||||
vol_conclusion += ",近5日明显放量"
|
|
||||||
elif vol_ratio < 0.7:
|
|
||||||
vol_conclusion += ",近5日缩量"
|
|
||||||
parts.append(vol_conclusion)
|
|
||||||
|
|
||||||
# ── MACD 结论(节奏参考) ──
|
|
||||||
dif = last.get("dif", 0) or 0
|
|
||||||
dea = last.get("dea", 0) or 0
|
|
||||||
macd_desc = ""
|
|
||||||
if len(df) >= 3:
|
|
||||||
prev_dif = df.iloc[-2].get("dif", 0) or 0
|
|
||||||
prev_dea = df.iloc[-2].get("dea", 0) or 0
|
|
||||||
if dif > dea and prev_dif <= prev_dea:
|
|
||||||
macd_desc = "MACD刚金叉"
|
|
||||||
elif dif > dea:
|
|
||||||
macd_desc = "MACD金叉运行中"
|
|
||||||
elif dif < dea and prev_dif >= prev_dea:
|
|
||||||
macd_desc = "MACD刚死叉"
|
|
||||||
elif dif < dea:
|
|
||||||
macd_desc = "MACD死叉运行中"
|
|
||||||
|
|
||||||
if dif > 0:
|
|
||||||
macd_desc += ",零轴上方(偏多)"
|
|
||||||
else:
|
|
||||||
macd_desc += ",零轴下方(偏空)"
|
|
||||||
parts.append((macd_desc or "MACD数据不足") + ";仅作节奏参考")
|
|
||||||
|
|
||||||
# ── RSI 结论(风险提示,不做买卖裁判) ──
|
|
||||||
rsi = last.get("rsi14", 50)
|
|
||||||
if not pd.isna(rsi):
|
|
||||||
if rsi > 80:
|
|
||||||
parts.append(f"RSI14={rsi:.0f},偏热,提示追高风险但不单独否决资金顺势")
|
|
||||||
elif rsi > 70:
|
|
||||||
parts.append(f"RSI14={rsi:.0f},偏高,注意追高风险")
|
|
||||||
elif rsi >= 40:
|
|
||||||
parts.append(f"RSI14={rsi:.0f},节奏中性")
|
|
||||||
else:
|
|
||||||
parts.append(f"RSI14={rsi:.0f},偏低,提示弱势或反弹弹性,不单独构成买点")
|
|
||||||
|
|
||||||
# ── 价格位置结论 ──
|
|
||||||
if tech_signal:
|
|
||||||
pos_parts = []
|
|
||||||
if tech_signal.rally_pct_5d > 15:
|
|
||||||
pos_parts.append(f"5日已涨{tech_signal.rally_pct_5d}%,追高风险大")
|
|
||||||
elif tech_signal.rally_pct_5d > 8:
|
|
||||||
pos_parts.append(f"5日涨{tech_signal.rally_pct_5d}%,短期有一定涨幅")
|
|
||||||
elif tech_signal.rally_pct_5d > 0:
|
|
||||||
pos_parts.append(f"5日涨{tech_signal.rally_pct_5d}%,涨幅温和")
|
|
||||||
else:
|
|
||||||
pos_parts.append(f"5日跌{abs(tech_signal.rally_pct_5d)}%,回调中")
|
|
||||||
|
|
||||||
if tech_signal.distance_from_high >= 0:
|
|
||||||
pos_parts.append("处于60日新高附近")
|
|
||||||
elif tech_signal.distance_from_high > -5:
|
|
||||||
pos_parts.append(f"距60日高点{abs(tech_signal.distance_from_high):.1f}%")
|
|
||||||
else:
|
|
||||||
pos_parts.append(f"距60日高点{abs(tech_signal.distance_from_high):.1f}%,位置较低")
|
|
||||||
|
|
||||||
parts.append("位置: " + ",".join(pos_parts))
|
|
||||||
|
|
||||||
# ── 近5日价格走势简述 ──
|
|
||||||
if len(df) >= 5:
|
|
||||||
recent_5 = df.tail(5)
|
|
||||||
closes = recent_5["close"].tolist()
|
|
||||||
first_c = closes[0]
|
|
||||||
last_c = closes[-1]
|
|
||||||
pct_5d = (last_c - first_c) / first_c * 100
|
|
||||||
parts.append(f"当前价: {last_c:.2f},5日{'涨' if pct_5d >= 0 else '跌'}{abs(pct_5d):.1f}%")
|
|
||||||
|
|
||||||
return "\n".join(parts)
|
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
"""LLM 候选预筛 + 逐股深度分析
|
"""历史 LLM 候选预筛 + 逐股深度分析工具。
|
||||||
|
|
||||||
|
弃用说明:
|
||||||
|
生产推荐链路禁止调用本模块。A 股批量选股必须由热点/催化、资金流、
|
||||||
|
情绪地位和规则化交易计划决定,LLM 只能做用户主动触发的解释、会诊、
|
||||||
|
离线复盘或新闻催化归因。
|
||||||
|
|
||||||
先做轻量预筛,控制深度裁决成本;
|
先做轻量预筛,控制深度裁决成本;
|
||||||
再对重点股票单独调用 LLM 做深度分析。
|
再对重点股票单独调用 LLM 做深度分析。
|
||||||
|
|||||||
@ -42,15 +42,17 @@ CHAT_SYSTEM_PROMPT = """\
|
|||||||
3. 查询当前用户的自选股池与最新建议
|
3. 查询当前用户的自选股池与最新建议
|
||||||
4. 查询个股K线、技术面、资金流向数据
|
4. 查询个股K线、技术面、资金流向数据
|
||||||
5. 搜索股票代码,并把结果放回当前交易语境中分析
|
5. 搜索股票代码,并把结果放回当前交易语境中分析
|
||||||
6. 对单只股票生成系统化会诊,输出结论、触发条件、失效条件、仓位边界和风险清单
|
6. 在用户明确要求时,对单只股票生成系统化会诊,输出结论、触发条件、失效条件、仓位边界和风险清单
|
||||||
|
|
||||||
重要提醒:
|
重要提醒:
|
||||||
- 回答用户关于"今天市场怎么样"之类的问题时,必须调用 get_realtime_indices 获取实时指数数据
|
- 回答用户关于"今天市场怎么样"之类的问题时,必须调用 get_realtime_indices 获取实时指数数据
|
||||||
- 回答用户关于"今天该怎么做"、"当前推荐怎么看"、"自选股该怎么处理"这类问题时,优先调用 get_strategy_board、get_latest_recommendations、get_user_watchlist_snapshot
|
- 回答用户关于"今天该怎么做"、"当前推荐怎么看"、"自选股该怎么处理"这类问题时,优先调用 get_strategy_board、get_latest_recommendations、get_user_watchlist_snapshot
|
||||||
- 回答用户关于某只股票能不能看、是否该买、持仓怎么处理、为什么涨跌、是否要复盘时,必须先 search_stock(如果用户没给标准 ts_code),再调用 diagnose_stock;必要时补充 get_stock_capital_flow、get_stock_technical_signal
|
- 回答用户关于某只股票时,先 search_stock(如果用户没给标准 ts_code),再用 get_latest_recommendations、get_user_watchlist_snapshot、get_stock_capital_flow、get_stock_technical_signal 等工具读取现有证据
|
||||||
|
- 只有当用户明确要求"诊断"、"会诊"、"深度分析"、"生成报告"或正在使用诊断页面时,才调用 diagnose_stock;不要在普通聊天、列表解释、推荐解读中自动触发个股会诊
|
||||||
- 盘中时段(9:30-15:00)必须使用实时数据,盘后时段使用当日收盘或最近一次系统生成的数据
|
- 盘中时段(9:30-15:00)必须使用实时数据,盘后时段使用当日收盘或最近一次系统生成的数据
|
||||||
- 不要脱离系统上下文泛泛而谈,必须先调用工具获取最新结果再回答
|
- 不要脱离系统上下文泛泛而谈,必须先调用工具获取最新结果再回答
|
||||||
- 个股分析必须优先看资金流向、主线板块、量价关系、价格行为和位置边界;RSI/MACD/KDJ 只做最后的节奏与风控备注,不能因超买超卖本身直接否决或买入
|
- 个股分析必须优先看资金流向、主线板块、量价关系、价格行为和位置边界;RSI/MACD/KDJ 只做最后的节奏与风控备注,不能因超买超卖本身直接否决或买入
|
||||||
|
- LLM 只负责解释、归纳和生成用户主动请求的诊断文本,不参与批量选股、排序、行动计划、止盈止损或策略换挡
|
||||||
|
|
||||||
回答要求:
|
回答要求:
|
||||||
1. 使用工具获取最新数据后再回答,不要凭空编造数据
|
1. 使用工具获取最新数据后再回答,不要凭空编造数据
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
"""动态策略选择器
|
"""动态策略选择器
|
||||||
|
|
||||||
在固定筛选引擎前增加一层“先选打法,再选股票”的策略决策。
|
在固定筛选引擎前增加一层“先选打法,再选股票”的策略决策。
|
||||||
规则负责稳定分类,LLM 负责补充语义判断和操作建议。
|
生产筛选只使用规则和策略配置,保证同一份行情输入得到稳定输出。
|
||||||
|
LLM 只能用于离线复盘、配置建议或解释,不参与盘中策略换挡。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@ -125,14 +126,7 @@ async def select_strategy_profile(
|
|||||||
from app.llm.strategy_config import load_active_strategy_profile
|
from app.llm.strategy_config import load_active_strategy_profile
|
||||||
|
|
||||||
profile = _select_rule_profile(market_temp, hot_sectors, intraday)
|
profile = _select_rule_profile(market_temp, hot_sectors, intraday)
|
||||||
profile = await load_active_strategy_profile(profile)
|
return await load_active_strategy_profile(profile)
|
||||||
|
|
||||||
if settings.deepseek_api_key:
|
|
||||||
llm_profile = await _select_llm_profile(market_temp, hot_sectors, intraday, profile)
|
|
||||||
if llm_profile:
|
|
||||||
profile = await load_active_strategy_profile(llm_profile)
|
|
||||||
|
|
||||||
return profile
|
|
||||||
|
|
||||||
|
|
||||||
def _select_rule_profile(
|
def _select_rule_profile(
|
||||||
|
|||||||
5
backend/app/news/__init__.py
Normal file
5
backend/app/news/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""新闻源采集层。
|
||||||
|
|
||||||
|
该层只负责把外部新闻落到本地数据库。题材归因由 catalyst 层处理,
|
||||||
|
页面和普通 API 不应直接触发外部新闻抓取。
|
||||||
|
"""
|
||||||
189
backend/app/news/collector.py
Normal file
189
backend/app/news/collector.py
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
"""多源新闻采集器。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import email.utils
|
||||||
|
import html
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.data.tushare_client import tushare_client
|
||||||
|
from app.news.models import NewsItem
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
_tushare_source_cursor = 0
|
||||||
|
|
||||||
|
RSS_HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_news_sources(
|
||||||
|
lookback_hours: int | None = None,
|
||||||
|
limit_per_source: int | None = None,
|
||||||
|
) -> list[NewsItem]:
|
||||||
|
"""从配置的数据源采集新闻。失败源隔离,不影响其他源。"""
|
||||||
|
lookback_hours = lookback_hours or settings.news_fetch_lookback_hours
|
||||||
|
limit_per_source = limit_per_source or settings.news_fetch_limit_per_source
|
||||||
|
items: list[NewsItem] = []
|
||||||
|
|
||||||
|
for source in _select_tushare_sources_for_run():
|
||||||
|
try:
|
||||||
|
items.extend(await _collect_tushare_news(source, lookback_hours, limit_per_source))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Tushare 新闻源采集失败 source=%s error=%s", source, e)
|
||||||
|
|
||||||
|
rss_sources = _parse_rss_sources(settings.news_rss_sources)
|
||||||
|
if rss_sources:
|
||||||
|
async with httpx.AsyncClient(headers=RSS_HEADERS, timeout=10.0, follow_redirects=True) as client:
|
||||||
|
for name, url in rss_sources:
|
||||||
|
try:
|
||||||
|
items.extend(await _collect_rss(client, name, url, lookback_hours, limit_per_source))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("RSS 新闻源采集失败 source=%s url=%s error=%s", name, url, e)
|
||||||
|
|
||||||
|
return _dedup_in_memory(items)
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_tushare_news(source: str, lookback_hours: int, limit: int) -> list[NewsItem]:
|
||||||
|
df = tushare_client.get_news(
|
||||||
|
source=source,
|
||||||
|
start_time=datetime.now() - timedelta(hours=lookback_hours),
|
||||||
|
end_time=datetime.now(),
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
items: list[NewsItem] = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
title = _clean_text(row.get("title", ""))
|
||||||
|
if len(title) < settings.news_min_title_length:
|
||||||
|
continue
|
||||||
|
content = _clean_text(row.get("content", ""))
|
||||||
|
items.append(NewsItem(
|
||||||
|
title=title,
|
||||||
|
content=content,
|
||||||
|
summary=_clean_text(row.get("summary", "")),
|
||||||
|
source=f"tushare:{source}",
|
||||||
|
url=str(row.get("url", "") or ""),
|
||||||
|
published_at=_parse_datetime(row.get("datetime") or row.get("time") or row.get("publish_time")),
|
||||||
|
))
|
||||||
|
return items[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_rss(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
source: str,
|
||||||
|
url: str,
|
||||||
|
lookback_hours: int,
|
||||||
|
limit: int,
|
||||||
|
) -> list[NewsItem]:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
root = ET.fromstring(resp.content)
|
||||||
|
cutoff = datetime.now() - timedelta(hours=lookback_hours)
|
||||||
|
items: list[NewsItem] = []
|
||||||
|
|
||||||
|
for item in root.findall(".//item")[: limit * 2]:
|
||||||
|
title = _clean_text(_xml_text(item, "title"))
|
||||||
|
if len(title) < settings.news_min_title_length:
|
||||||
|
continue
|
||||||
|
published_at = _parse_datetime(_xml_text(item, "pubDate"))
|
||||||
|
if published_at and published_at < cutoff:
|
||||||
|
continue
|
||||||
|
summary = _clean_text(_xml_text(item, "description"))
|
||||||
|
items.append(NewsItem(
|
||||||
|
title=title,
|
||||||
|
content=summary,
|
||||||
|
summary=summary[:240],
|
||||||
|
source=f"rss:{source}",
|
||||||
|
url=_clean_text(_xml_text(item, "link")),
|
||||||
|
published_at=published_at,
|
||||||
|
))
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _split_csv(value: str) -> list[str]:
|
||||||
|
return [item.strip() for item in (value or "").split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rss_sources(value: str) -> list[tuple[str, str]]:
|
||||||
|
result: list[tuple[str, str]] = []
|
||||||
|
for chunk in _split_csv(value):
|
||||||
|
if "|" not in chunk:
|
||||||
|
continue
|
||||||
|
name, url = chunk.split("|", 1)
|
||||||
|
name = name.strip()
|
||||||
|
url = url.strip()
|
||||||
|
if name and url:
|
||||||
|
result.append((name, url))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _select_tushare_sources_for_run() -> list[str]:
|
||||||
|
"""Tushare news 免费/低权限账号通常限制 1 次/分钟,每轮只取少量源。"""
|
||||||
|
global _tushare_source_cursor
|
||||||
|
|
||||||
|
sources = _split_csv(settings.news_tushare_sources)
|
||||||
|
if not sources:
|
||||||
|
return []
|
||||||
|
|
||||||
|
limit = max(1, min(int(settings.news_tushare_sources_per_run or 1), len(sources)))
|
||||||
|
selected: list[str] = []
|
||||||
|
for offset in range(limit):
|
||||||
|
selected.append(sources[(_tushare_source_cursor + offset) % len(sources)])
|
||||||
|
_tushare_source_cursor = (_tushare_source_cursor + limit) % len(sources)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def _xml_text(item: ET.Element, tag: str) -> str:
|
||||||
|
node = item.find(tag)
|
||||||
|
return node.text if node is not None and node.text else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_text(value) -> str:
|
||||||
|
text = html.unescape(str(value or ""))
|
||||||
|
text = re.sub(r"<[^>]+>", " ", text)
|
||||||
|
text = re.sub(r"\s+", " ", text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_datetime(value) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.replace(tzinfo=None)
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y%m%d%H%M%S", "%Y%m%d"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(text[: len(fmt)], fmt)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
parsed = email.utils.parsedate_to_datetime(text)
|
||||||
|
return parsed.replace(tzinfo=None)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _dedup_in_memory(items: list[NewsItem]) -> list[NewsItem]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[NewsItem] = []
|
||||||
|
for item in items:
|
||||||
|
key = re.sub(r"\W+", "", item.title.lower())[:80]
|
||||||
|
if not key or key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
13
backend/app/news/models.py
Normal file
13
backend/app/news/models.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
"""新闻采集领域模型。"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class NewsItem(BaseModel):
|
||||||
|
title: str
|
||||||
|
content: str = ""
|
||||||
|
summary: str = ""
|
||||||
|
source: str = ""
|
||||||
|
url: str = ""
|
||||||
|
published_at: datetime | None = None
|
||||||
50
backend/app/news/pipeline.py
Normal file
50
backend/app/news/pipeline.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"""新闻采集与催化归因流水线。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.catalyst.service import analyze_pending_news, ingest_news_items
|
||||||
|
from app.news.collector import collect_news_sources
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_news_catalysts(
|
||||||
|
lookback_hours: int | None = None,
|
||||||
|
limit_per_source: int | None = None,
|
||||||
|
analyze_limit: int | None = None,
|
||||||
|
use_llm: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
if not settings.news_collection_enabled:
|
||||||
|
return {
|
||||||
|
"enabled": False,
|
||||||
|
"fetched": 0,
|
||||||
|
"inserted": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"analyzed": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
"failed": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
items = await collect_news_sources(
|
||||||
|
lookback_hours=lookback_hours or settings.news_fetch_lookback_hours,
|
||||||
|
limit_per_source=limit_per_source or settings.news_fetch_limit_per_source,
|
||||||
|
)
|
||||||
|
ingest_result = await ingest_news_items(items)
|
||||||
|
remaining_result = await analyze_pending_news(
|
||||||
|
limit=analyze_limit or settings.news_analyze_limit_per_run,
|
||||||
|
use_llm=use_llm,
|
||||||
|
)
|
||||||
|
result = {
|
||||||
|
"enabled": True,
|
||||||
|
"fetched": len(items),
|
||||||
|
"inserted": ingest_result.get("inserted", 0),
|
||||||
|
"duplicates": ingest_result.get("duplicates", 0),
|
||||||
|
"analyzed": ingest_result.get("analyzed", 0) + remaining_result.get("analyzed", 0),
|
||||||
|
"skipped": remaining_result.get("skipped", 0),
|
||||||
|
"failed": ingest_result.get("failed", 0) + remaining_result.get("failed", 0),
|
||||||
|
}
|
||||||
|
logger.info("新闻催化刷新完成: %s", result)
|
||||||
|
return result
|
||||||
@ -606,7 +606,7 @@ function FocusStockCard({ rec }: { rec: RecommendationData }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-1.5 text-xs leading-5 text-text-secondary line-clamp-2">
|
<div className="mt-1.5 text-xs leading-5 text-text-secondary line-clamp-2">
|
||||||
{rec.decision_trace?.headline ?? rec.trigger_condition ?? rec.entry_timing ?? rec.prefilter_reason ?? rec.reasons?.[0] ?? "等待新的触发条件。"}
|
{rec.decision_trace?.headline ?? rec.trigger_condition ?? rec.entry_timing ?? rec.reasons?.[0] ?? "等待新的触发条件。"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(rec.invalidation_condition || rec.risk_note) ? (
|
{(rec.invalidation_condition || rec.risk_note) ? (
|
||||||
|
|||||||
429
frontend/src/app/(auth)/sentiment/page.tsx
Normal file
429
frontend/src/app/(auth)/sentiment/page.tsx
Normal file
@ -0,0 +1,429 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { ErrorBoundary } from "@/components/error-boundary";
|
||||||
|
import { fetchAPI } from "@/lib/api";
|
||||||
|
import type { CatalystEvent, CatalystNewsItem, ThemeCatalystScore } from "@/lib/api";
|
||||||
|
import { useWebSocket } from "@/hooks/use-websocket";
|
||||||
|
|
||||||
|
type CatalystTone = "hot" | "warm" | "quiet";
|
||||||
|
|
||||||
|
export default function SentimentPage() {
|
||||||
|
const [news, setNews] = useState<CatalystNewsItem[]>([]);
|
||||||
|
const [events, setEvents] = useState<CatalystEvent[]>([]);
|
||||||
|
const [themes, setThemes] = useState<ThemeCatalystScore[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [newsData, eventData, themeData] = await Promise.all([
|
||||||
|
fetchAPI<CatalystNewsItem[]>("/api/catalysts/news?limit=80&hours=72").catch(() => []),
|
||||||
|
fetchAPI<CatalystEvent[]>("/api/catalysts/recent?limit=60&hours=72").catch(() => []),
|
||||||
|
fetchAPI<ThemeCatalystScore[]>("/api/catalysts/theme-scores?limit=20&hours=72").catch(() => []),
|
||||||
|
]);
|
||||||
|
setNews(newsData);
|
||||||
|
setEvents(eventData);
|
||||||
|
setThemes(themeData);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
useWebSocket(
|
||||||
|
useCallback((message: { type: string }) => {
|
||||||
|
if (message.type === "news_catalysts_ready" || message.type === "sector_scan_ready" || message.type === "scan_complete") {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}, [loadData])
|
||||||
|
);
|
||||||
|
|
||||||
|
const analyzedCount = news.filter((item) => item.status === "analyzed").length;
|
||||||
|
const pendingCount = news.filter((item) => item.status === "pending").length;
|
||||||
|
const failedCount = news.filter((item) => item.status === "failed").length;
|
||||||
|
const topTheme = themes[0];
|
||||||
|
const headline = buildSentimentHeadline(themes, events);
|
||||||
|
|
||||||
|
const filteredNews = useMemo(() => {
|
||||||
|
if (statusFilter === "all") return news;
|
||||||
|
return news.filter((item) => item.status === statusFilter);
|
||||||
|
}, [news, statusFilter]);
|
||||||
|
|
||||||
|
const eventsById = useMemo(() => {
|
||||||
|
const map = new Map<number, CatalystEvent>();
|
||||||
|
events.forEach((event) => map.set(event.id, event));
|
||||||
|
return map;
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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="animate-fade-in-up">
|
||||||
|
<h1 className="text-lg font-bold tracking-tight">舆情雷达</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<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>
|
||||||
|
<p className="mt-2 max-w-3xl text-sm leading-6 text-text-secondary">{headline.detail}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 md:w-[280px] shrink-0">
|
||||||
|
<RadarMetric label="已分析" value={analyzedCount} tone="hot" />
|
||||||
|
<RadarMetric label="待处理" value={pendingCount} tone="warm" />
|
||||||
|
<RadarMetric label="强主题" value={themes.filter((item) => item.catalyst_score >= 70).length} tone="hot" />
|
||||||
|
<RadarMetric label="异常" value={failedCount} tone={failedCount ? "warm" : "quiet"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<RadarDecision title="当前重点" value={topTheme?.theme_name ?? "暂无"} detail={topTheme ? `催化分 ${topTheme.catalyst_score.toFixed(0)},${topTheme.catalyst_count} 条线索` : "等待后台新闻归因。"} tone="hot" />
|
||||||
|
<RadarDecision title="观察方式" value={headline.action} detail="只作为主题热度和催化背景,不直接生成买卖结论。" tone="warm" />
|
||||||
|
<RadarDecision title="失效信号" value={headline.risk} detail="当新闻无法映射到资金主线时,降低权重。" tone="quiet" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="glass-card-static p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">主题催化排行</h2>
|
||||||
|
<span className="text-[10px] text-text-muted">72小时</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-2.5">
|
||||||
|
{themes.length ? themes.slice(0, 8).map((theme, index) => (
|
||||||
|
<ThemePulseRow key={theme.theme_id} theme={theme} rank={index + 1} />
|
||||||
|
)) : (
|
||||||
|
<EmptyLine text={loading ? "加载舆情主题..." : "暂无主题催化"} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_380px] gap-4 animate-fade-in-up">
|
||||||
|
<section className="glass-card-static p-4 md:p-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">舆情流</h2>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">后台抓取的新闻、政策和公告线索。</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[
|
||||||
|
{ key: "all", label: "全部", count: news.length },
|
||||||
|
{ key: "analyzed", label: "已归因", count: analyzedCount },
|
||||||
|
{ key: "pending", label: "待处理", count: pendingCount },
|
||||||
|
{ key: "failed", label: "异常", count: failedCount },
|
||||||
|
].map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setStatusFilter(tab.key)}
|
||||||
|
className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition-all ${
|
||||||
|
statusFilter === tab.key
|
||||||
|
? "border-amber-500/20 bg-amber-500/10 text-amber-300"
|
||||||
|
: "border-border-subtle bg-surface-1 text-text-muted hover:text-text-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label} {tab.count}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{filteredNews.length ? filteredNews.map((item) => (
|
||||||
|
<NewsRow key={item.id} item={item} event={item.catalyst_id ? eventsById.get(item.catalyst_id) : undefined} />
|
||||||
|
)) : (
|
||||||
|
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-8 text-center text-sm text-text-muted">
|
||||||
|
{loading ? "加载舆情流..." : "暂无符合条件的舆情。"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<section className="glass-card-static p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">AI 归因摘要</h2>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{events.length ? events.slice(0, 6).map((event) => (
|
||||||
|
<CatalystInsight key={event.id} event={event} />
|
||||||
|
)) : (
|
||||||
|
<EmptyLine text={loading ? "加载归因结果..." : "暂无 AI 归因结果"} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="glass-card-static p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">使用边界</h2>
|
||||||
|
<div className="mt-3 space-y-2 text-xs leading-6 text-text-secondary">
|
||||||
|
<BoundaryLine text="舆情只解释催化方向,不直接给出买卖。" />
|
||||||
|
<BoundaryLine text="主题催化需要和资金流、前排强度共同确认。" />
|
||||||
|
<BoundaryLine text="页面只读取本地数据库,不触发外部抓取。" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSentimentHeadline(themes: ThemeCatalystScore[], events: CatalystEvent[]) {
|
||||||
|
const top = themes[0];
|
||||||
|
const strongCount = themes.filter((item) => item.catalyst_score >= 70).length;
|
||||||
|
const policyCount = events.filter((item) => item.catalyst_type === "policy").length;
|
||||||
|
|
||||||
|
if (!top) {
|
||||||
|
return {
|
||||||
|
title: "舆情等待后台归因",
|
||||||
|
detail: "新闻采集和催化分析会在后台任务完成后更新。",
|
||||||
|
action: "等待线索",
|
||||||
|
risk: "无数据不判断",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (top.catalyst_score >= 75) {
|
||||||
|
return {
|
||||||
|
title: `${top.theme_name} 舆情升温`,
|
||||||
|
detail: `过去 72 小时 ${top.catalyst_count} 条催化线索,${strongCount} 个主题达到强催化阈值。${policyCount ? `其中政策类线索 ${policyCount} 条。` : ""}`,
|
||||||
|
action: "优先核对资金回流",
|
||||||
|
risk: "热度兑现",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${top.theme_name} 有线索但未形成强共振`,
|
||||||
|
detail: `当前催化分 ${top.catalyst_score.toFixed(0)},更适合观察是否被资金和板块前排确认。`,
|
||||||
|
action: "跟踪扩散",
|
||||||
|
risk: "证据不足",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadarMetric({ label, value, tone }: { label: string; value: number; tone: CatalystTone }) {
|
||||||
|
const toneClass = getToneClass(tone);
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border px-3 py-2 ${toneClass.box}`}>
|
||||||
|
<div className="text-[10px] text-text-muted">{label}</div>
|
||||||
|
<div className={`mt-1 font-mono text-xl font-bold tabular-nums ${toneClass.text}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadarDecision({ title, value, detail, tone }: { title: string; value: string; detail: string; tone: CatalystTone }) {
|
||||||
|
const toneClass = getToneClass(tone);
|
||||||
|
return (
|
||||||
|
<div className={`rounded-2xl border p-3 ${toneClass.box}`}>
|
||||||
|
<div className="text-[11px] font-semibold text-text-secondary">{title}</div>
|
||||||
|
<div className={`mt-1 text-sm font-bold ${toneClass.text}`}>{value}</div>
|
||||||
|
<div className="mt-1 text-xs leading-5 text-text-muted">{detail}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemePulseRow({ theme, rank }: { theme: ThemeCatalystScore; rank: number }) {
|
||||||
|
const score = Math.max(0, Math.min(theme.catalyst_score, 100));
|
||||||
|
const tone = score >= 70 ? "hot" : score >= 45 ? "warm" : "quiet";
|
||||||
|
const toneClass = getToneClass(tone);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex gap-3">
|
||||||
|
<div className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border font-mono text-xs ${toneClass.box} ${toneClass.text}`}>
|
||||||
|
{rank}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-text-primary">{theme.theme_name}</div>
|
||||||
|
<div className="mt-1 text-[11px] text-text-muted line-clamp-1">
|
||||||
|
{theme.top_reasons?.[0] ?? "等待更多舆情证据"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`font-mono text-sm font-bold tabular-nums ${toneClass.text}`}>{score.toFixed(0)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-1.5 rounded-full bg-surface-2 overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${toneClass.bar}`} style={{ width: `${score}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewsRow({ item, event }: { item: CatalystNewsItem; event?: CatalystEvent }) {
|
||||||
|
const status = getStatusMeta(item.status);
|
||||||
|
const eventTone = event ? getEventTone(event) : "quiet";
|
||||||
|
const toneClass = getToneClass(eventTone);
|
||||||
|
const title = event?.title || item.title;
|
||||||
|
const href = item.url || event?.url || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4 transition-colors hover:border-amber-500/15">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={`rounded-md border px-2 py-0.5 text-[10px] ${status.className}`}>{status.label}</span>
|
||||||
|
<span className="text-[10px] text-text-muted">{formatSource(item.source)}</span>
|
||||||
|
<span className="text-[10px] text-text-muted">{formatDateTime(item.published_at || item.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
{href ? (
|
||||||
|
<a href={href} target="_blank" rel="noreferrer" className="mt-2 block text-sm font-semibold leading-6 text-text-primary hover:text-amber-300">
|
||||||
|
{title}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<h3 className="mt-2 text-sm font-semibold leading-6 text-text-primary">{title}</h3>
|
||||||
|
)}
|
||||||
|
{event?.summary || event?.llm_reason ? (
|
||||||
|
<p className="mt-2 text-xs leading-6 text-text-secondary line-clamp-2">
|
||||||
|
{event.llm_reason || event.summary}
|
||||||
|
</p>
|
||||||
|
) : item.error ? (
|
||||||
|
<p className="mt-2 text-xs leading-6 text-amber-300/80 line-clamp-2">{item.error}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2 md:w-[240px] shrink-0">
|
||||||
|
<MiniScore label="强度" value={event?.strength} tone={eventTone} />
|
||||||
|
<MiniScore label="新鲜" value={event?.freshness} tone={eventTone} />
|
||||||
|
<MiniScore label="可信" value={event?.confidence} tone={eventTone} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{event?.themes ? (
|
||||||
|
<div className="mt-3 rounded-xl border border-border-subtle bg-surface-2/70 px-3 py-2 text-[11px] leading-5 text-text-secondary">
|
||||||
|
归因:{event.themes}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{event ? (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<span className={`rounded-md border px-2 py-1 text-[10px] ${toneClass.box} ${toneClass.text}`}>{formatCatalystType(event.catalyst_type)}</span>
|
||||||
|
<span className="rounded-md border border-border-subtle bg-surface-2 px-2 py-1 text-[10px] text-text-muted">本地已归档</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CatalystInsight({ event }: { event: CatalystEvent }) {
|
||||||
|
const tone = getEventTone(event);
|
||||||
|
const toneClass = getToneClass(tone);
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={`rounded-md border px-2 py-0.5 text-[10px] ${toneClass.box} ${toneClass.text}`}>{formatCatalystType(event.catalyst_type)}</span>
|
||||||
|
<span className="text-[10px] text-text-muted">{formatDateTime(event.published_at || event.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm font-semibold leading-6 text-text-primary line-clamp-2">{event.title}</div>
|
||||||
|
<div className="mt-1 text-xs leading-5 text-text-secondary line-clamp-3">
|
||||||
|
{event.llm_reason || event.summary || "等待更多解释"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`font-mono text-sm font-bold tabular-nums ${toneClass.text}`}>{Math.round(event.strength)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniScore({ label, value, tone }: { label: string; value?: number; tone: CatalystTone }) {
|
||||||
|
const toneClass = getToneClass(tone);
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-surface-2 px-2 py-1.5">
|
||||||
|
<div className="text-[10px] text-text-muted">{label}</div>
|
||||||
|
<div className={`mt-0.5 font-mono text-xs font-semibold tabular-nums ${value == null ? "text-text-muted" : toneClass.text}`}>
|
||||||
|
{value == null ? "--" : Math.round(value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BoundaryLine({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400" />
|
||||||
|
<span>{text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyLine({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-4 text-center text-xs text-text-muted">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToneClass(tone: CatalystTone) {
|
||||||
|
if (tone === "hot") {
|
||||||
|
return {
|
||||||
|
box: "border-red-500/15 bg-red-500/[0.08]",
|
||||||
|
text: "text-red-300",
|
||||||
|
bar: "bg-red-400",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (tone === "warm") {
|
||||||
|
return {
|
||||||
|
box: "border-amber-500/15 bg-amber-500/[0.08]",
|
||||||
|
text: "text-amber-300",
|
||||||
|
bar: "bg-amber-400",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
box: "border-border-subtle bg-surface-2",
|
||||||
|
text: "text-text-secondary",
|
||||||
|
bar: "bg-text-muted",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventTone(event: CatalystEvent): CatalystTone {
|
||||||
|
if (event.strength >= 70 || event.confidence >= 75) return "hot";
|
||||||
|
if (event.strength >= 45 || event.freshness >= 55) return "warm";
|
||||||
|
return "quiet";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusMeta(status: string) {
|
||||||
|
if (status === "analyzed") {
|
||||||
|
return { label: "已归因", className: "border-emerald-500/15 bg-emerald-500/10 text-emerald-300" };
|
||||||
|
}
|
||||||
|
if (status === "pending") {
|
||||||
|
return { label: "待处理", className: "border-amber-500/15 bg-amber-500/10 text-amber-300" };
|
||||||
|
}
|
||||||
|
if (status === "failed") {
|
||||||
|
return { label: "异常", className: "border-red-500/15 bg-red-500/10 text-red-300" };
|
||||||
|
}
|
||||||
|
if (status === "skipped") {
|
||||||
|
return { label: "已忽略", className: "border-border-subtle bg-surface-2 text-text-muted" };
|
||||||
|
}
|
||||||
|
return { label: status || "未知", className: "border-border-subtle bg-surface-2 text-text-muted" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCatalystType(type: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
policy: "政策",
|
||||||
|
industry: "产业",
|
||||||
|
event: "事件",
|
||||||
|
earnings: "业绩",
|
||||||
|
announcement: "公告",
|
||||||
|
news: "新闻",
|
||||||
|
};
|
||||||
|
return labels[type] ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSource(source: string) {
|
||||||
|
if (!source) return "未知来源";
|
||||||
|
return source.replace("tushare:", "").replace("rss:", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string | null) {
|
||||||
|
if (!value) return "暂无时间";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -107,7 +107,7 @@ export default function StockDetailPage() {
|
|||||||
|
|
||||||
const recFromHistory = history
|
const recFromHistory = history
|
||||||
.flatMap((group) => group.recommendations)
|
.flatMap((group) => group.recommendations)
|
||||||
.find((rec) => rec.ts_code === code);
|
.find((rec) => sameStockCode(rec.ts_code, code));
|
||||||
const rec = thesisData?.recommendation ?? recFromHistory ?? null;
|
const rec = thesisData?.recommendation ?? recFromHistory ?? null;
|
||||||
|
|
||||||
if (rec) {
|
if (rec) {
|
||||||
@ -122,11 +122,6 @@ export default function StockDetailPage() {
|
|||||||
setRecScore(null);
|
setRecScore(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
setQuote(null);
|
|
||||||
setSignals(null);
|
|
||||||
setKline([]);
|
|
||||||
setCapitalFlow([]);
|
|
||||||
setEvidenceLoaded(false);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
@ -138,35 +133,52 @@ export default function StockDetailPage() {
|
|||||||
};
|
};
|
||||||
}, [code]);
|
}, [code]);
|
||||||
|
|
||||||
const loadEvidence = async () => {
|
useEffect(() => {
|
||||||
if (!code || evidenceLoading || evidenceLoaded) return;
|
if (!code) return;
|
||||||
setEvidenceLoading(true);
|
|
||||||
try {
|
let cancelled = false;
|
||||||
const [quoteData, signalsData, klineData, flowData] = await Promise.all([
|
|
||||||
fetchAPI<QuoteData>(`/api/stocks/${code}/quote`).catch(() => null),
|
async function loadEvidence() {
|
||||||
fetchAPI<StockSignals>(`/api/stocks/${code}/signals`).catch(() => null),
|
setEvidenceLoading(true);
|
||||||
fetchAPI<unknown[]>(`/api/stocks/${code}/kline?days=120`).catch(() => []),
|
setEvidenceLoaded(false);
|
||||||
fetchAPI<FlowRecord[]>(`/api/stocks/${code}/capital_flow?days=10`).catch(() => []),
|
setQuote(null);
|
||||||
]);
|
setSignals(null);
|
||||||
setQuote(isValidQuote(quoteData) ? quoteData : null);
|
setKline([]);
|
||||||
setSignals(signalsData);
|
setCapitalFlow([]);
|
||||||
setKline(Array.isArray(klineData) ? klineData : []);
|
|
||||||
setCapitalFlow(Array.isArray(flowData) ? flowData : []);
|
try {
|
||||||
setEvidenceLoaded(true);
|
const [quoteData, signalsData, klineData, flowData] = await Promise.all([
|
||||||
} finally {
|
fetchAPI<QuoteData>(`/api/stocks/${code}/quote`).catch(() => null),
|
||||||
setEvidenceLoading(false);
|
fetchAPI<StockSignals>(`/api/stocks/${code}/signals`).catch(() => null),
|
||||||
|
fetchAPI<unknown[]>(`/api/stocks/${code}/kline?days=120`).catch(() => []),
|
||||||
|
fetchAPI<FlowRecord[]>(`/api/stocks/${code}/capital_flow?days=10`).catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
setQuote(isValidQuote(quoteData) ? quoteData : null);
|
||||||
|
setSignals(signalsData);
|
||||||
|
setKline(Array.isArray(klineData) ? klineData : []);
|
||||||
|
setCapitalFlow(Array.isArray(flowData) ? flowData : []);
|
||||||
|
setEvidenceLoaded(true);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setEvidenceLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
loadEvidence();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
const recommendation = thesis?.recommendation;
|
const recommendation = thesis?.recommendation;
|
||||||
const latestTracking = thesis?.latest_tracking;
|
const latestTracking = thesis?.latest_tracking;
|
||||||
const latestFlow = capitalFlow.length > 0 ? capitalFlow[capitalFlow.length - 1] : null;
|
const latestFlow = capitalFlow.length > 0 ? capitalFlow[capitalFlow.length - 1] : null;
|
||||||
const pageName = recommendation?.name || thesis?.name || quote?.name || code;
|
const pageName = recommendation?.name || thesis?.name || quote?.name || code;
|
||||||
const conviction = recommendation?.llm_score != null ? Math.round(recommendation.llm_score) : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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-6 pt-4 pb-20 md:pb-8 space-y-4">
|
||||||
<a
|
<a
|
||||||
href="/recommendations"
|
href="/recommendations"
|
||||||
className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-primary transition-colors animate-fade-in-up"
|
className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-primary transition-colors animate-fade-in-up"
|
||||||
@ -177,12 +189,11 @@ export default function StockDetailPage() {
|
|||||||
返回推荐池
|
返回推荐池
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div className="glass-card-static p-5 animate-fade-in-up overflow-hidden relative">
|
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up overflow-hidden relative">
|
||||||
<div className="absolute right-[-90px] top-[-120px] w-72 h-72 rounded-full bg-cyan-500/[0.04] blur-3xl pointer-events-none" />
|
<div className="relative grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px] gap-4">
|
||||||
<div className="relative grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-5">
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||||
<span className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold">个股作战卡</span>
|
<span className="text-[10px] tracking-[0.18em] text-cyan-400 font-semibold">个股作战卡</span>
|
||||||
{recommendation?.action_plan ? (
|
{recommendation?.action_plan ? (
|
||||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${getActionPlanClass(recommendation.action_plan)}`}>
|
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${getActionPlanClass(recommendation.action_plan)}`}>
|
||||||
{recommendation.action_plan}
|
{recommendation.action_plan}
|
||||||
@ -197,33 +208,34 @@ export default function StockDetailPage() {
|
|||||||
|
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">{pageName}</h1>
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">{pageName}</h1>
|
||||||
<span className="text-sm text-text-muted font-mono tabular-nums">{code}</span>
|
<span className="text-sm text-text-muted font-mono tabular-nums">{quote?.ts_code || thesis?.ts_code || code}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-text-secondary leading-relaxed mt-3 max-w-4xl">
|
<p className="text-sm text-text-secondary leading-relaxed mt-2 max-w-4xl">
|
||||||
{buildHeroSummary(thesis, quote)}
|
{buildHeroSummary(thesis, quote)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 mt-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 mt-3">
|
||||||
{(thesis?.decision_points ?? []).slice(0, 3).map((point) => (
|
<DecisionPoint label="触发条件" value={recommendation?.trigger_condition || "等待触发条件归档"} />
|
||||||
<DecisionPoint key={point.label} label={point.label} value={point.value} />
|
<DecisionPoint label="失效条件" value={recommendation?.invalidation_condition || "等待失效条件归档"} />
|
||||||
))}
|
<DecisionPoint label="仓位建议" value={recommendation?.suggested_position_pct != null ? `${recommendation.suggested_position_pct}%` : "未设置"} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl bg-surface-1/80 border border-border-subtle p-4">
|
<div className="rounded-xl bg-surface-1/80 border border-border-subtle p-4">
|
||||||
<SectionTitle title="今日处理" />
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="text-xs text-text-secondary leading-relaxed mt-2">
|
<SectionTitle title="实时盘面" />
|
||||||
{thesis ? "已读取最近推荐、跟踪和诊断记录。" : "加载中"}
|
<EvidenceStatus loading={evidenceLoading} loaded={evidenceLoaded} hasQuote={!!quote} />
|
||||||
</div>
|
</div>
|
||||||
|
<QuotePriceLine quote={quote} loading={evidenceLoading && !quote} />
|
||||||
<div className="grid grid-cols-2 gap-2 mt-3">
|
<div className="grid grid-cols-2 gap-2 mt-3">
|
||||||
<MiniDataCell label="当前动作" value={recommendation?.action_plan || "观察"} />
|
<MiniDataCell label="换手" value={quote?.turnover_rate != null ? `${quote.turnover_rate.toFixed(2)}%` : evidenceLoading ? "加载中" : "暂无"} />
|
||||||
<MiniDataCell label="把握度" value={conviction != null ? `${conviction}/10` : "暂无"} />
|
<MiniDataCell label="量比" value={quote?.volume_ratio != null ? quote.volume_ratio.toFixed(2) : evidenceLoading ? "加载中" : "暂无"} />
|
||||||
<MiniDataCell label="初步判断" value={formatPrefilterDecision(recommendation?.prefilter_decision)} />
|
<MiniDataCell label="主力净流" value={latestFlow ? formatFlowAmount(latestFlow.main_net_inflow) : evidenceLoading ? "加载中" : "暂无"} tone={latestFlow?.main_net_inflow} />
|
||||||
<MiniDataCell label="建议仓位" value={recommendation?.suggested_position_pct != null ? `${recommendation.suggested_position_pct}%` : "未设置"} />
|
<MiniDataCell label="位置" value={signals ? positionComment(recScore?.position_score ?? signals.position_score) : evidenceLoading ? "加载中" : "暂无"} />
|
||||||
</div>
|
</div>
|
||||||
{(recommendation?.recall_tags?.length ?? 0) > 0 ? (
|
{(recommendation?.recall_tags?.length ?? 0) > 0 ? (
|
||||||
<div className="flex flex-wrap gap-2 mt-3">
|
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||||
{(recommendation?.recall_tags ?? []).slice(0, 4).map((tag) => (
|
{(recommendation?.recall_tags ?? []).slice(0, 4).map((tag) => (
|
||||||
<span key={tag} className="text-[10px] px-2 py-1 rounded-md bg-surface-2 text-text-muted border border-border-subtle">
|
<span key={tag} className="text-[10px] px-2 py-1 rounded-md bg-surface-2 text-text-muted border border-border-subtle">
|
||||||
{formatRecallTag(tag)}
|
{formatRecallTag(tag)}
|
||||||
@ -250,45 +262,24 @@ export default function StockDetailPage() {
|
|||||||
<div className="glass-card-static p-8 text-center text-sm text-text-muted">加载个股推演中...</div>
|
<div className="glass-card-static p-8 text-center text-sm text-text-muted">加载个股推演中...</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4 animate-fade-in-up">
|
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px] gap-4 animate-fade-in-up">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<EvidenceCard recommendation={recommendation} quote={quote} signals={signals} loading={evidenceLoading} />
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{kline.length > 0 ? <KlineChart data={kline as never[]} /> : <ChartEmptyCard title="价格走势" description={evidenceLoading ? "行情证据加载中" : "暂无K线数据"} />}
|
||||||
|
{capitalFlow.length > 0 ? <CapitalFlowChart data={capitalFlow} /> : <ChartEmptyCard title="资金趋势" description={evidenceLoading ? "资金数据加载中" : "暂无资金流数据"} />}
|
||||||
|
</div>
|
||||||
|
{latestFlow ? <CapitalFlowBreakdown flow={latestFlow} /> : null}
|
||||||
<PlanCard recommendation={recommendation} trackingNote={latestTracking?.review_note || ""} />
|
<PlanCard recommendation={recommendation} trackingNote={latestTracking?.review_note || ""} />
|
||||||
<EvidenceCard recommendation={recommendation} quote={quote} signals={signals} />
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SignalSnapshot signals={signals} recScore={recScore} evidenceLoading={evidenceLoading} evidenceLoaded={evidenceLoaded} />
|
||||||
|
<QuoteSnapshot quote={quote} evidenceLoading={evidenceLoading} evidenceLoaded={evidenceLoaded} />
|
||||||
|
<TrackingCard tracking={latestTracking ?? null} />
|
||||||
<DiagnosisArchiveCard diagnoses={thesis?.diagnoses ?? []} />
|
<DiagnosisArchiveCard diagnoses={thesis?.diagnoses ?? []} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<TrackingCard tracking={latestTracking ?? null} />
|
|
||||||
<QuoteSnapshot quote={quote} evidenceLoaded={evidenceLoaded} />
|
|
||||||
<SignalSnapshot signals={signals} recScore={recScore} evidenceLoaded={evidenceLoaded} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-card-static p-4 animate-fade-in-up">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<SectionTitle title="行情证据" />
|
|
||||||
<button
|
|
||||||
onClick={loadEvidence}
|
|
||||||
disabled={evidenceLoading || evidenceLoaded}
|
|
||||||
className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-cyan-400 transition-colors disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{evidenceLoading ? "加载中..." : evidenceLoaded ? "已加载证据" : "加载行情证据"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{evidenceLoaded ? (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-fade-in-up">
|
|
||||||
{kline.length > 0 ? <KlineChart data={kline as never[]} /> : <ChartEmptyCard title="K线图" description="暂无K线数据" />}
|
|
||||||
{capitalFlow.length > 0 ? <CapitalFlowChart data={capitalFlow} /> : <ChartEmptyCard title="资金流向趋势" description="暂无资金流数据" />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{latestFlow ? (
|
|
||||||
<CapitalFlowBreakdown flow={latestFlow} />
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -307,12 +298,11 @@ function PlanCard({
|
|||||||
<div className="glass-card-static p-5">
|
<div className="glass-card-static p-5">
|
||||||
<div className="flex items-center justify-between gap-3 mb-3">
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
<SectionTitle title="执行计划" />
|
<SectionTitle title="执行计划" />
|
||||||
{recommendation?.llm_score != null ? (
|
{recommendation?.score != null ? (
|
||||||
<span className="text-xs font-mono tabular-nums text-cyan-400/80">把握度 {Math.round(recommendation.llm_score)}/10</span>
|
<span className="text-xs font-mono tabular-nums text-cyan-400/80">规则分 {Math.round(recommendation.score)}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
{recommendation?.prefilter_reason ? <PlanRow label="初筛理由" value={recommendation.prefilter_reason} /> : null}
|
|
||||||
{(recommendation?.focus_points?.length ?? 0) > 0 ? (
|
{(recommendation?.focus_points?.length ?? 0) > 0 ? (
|
||||||
<PlanRow label="关注点" value={(recommendation?.focus_points ?? []).slice(0, 3).join(" / ")} />
|
<PlanRow label="关注点" value={(recommendation?.focus_points ?? []).slice(0, 3).join(" / ")} />
|
||||||
) : null}
|
) : null}
|
||||||
@ -328,6 +318,44 @@ function PlanCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EvidenceStatus({ loading, loaded, hasQuote }: { loading: boolean; loaded: boolean; hasQuote: boolean }) {
|
||||||
|
const text = loading ? "同步中" : loaded && hasQuote ? "已同步" : loaded ? "暂无行情" : "等待同步";
|
||||||
|
const color = loading
|
||||||
|
? "border-amber-500/20 bg-amber-500/10 text-amber-400"
|
||||||
|
: loaded && hasQuote
|
||||||
|
? "border-cyan-500/20 bg-cyan-500/10 text-cyan-400"
|
||||||
|
: "border-border-subtle bg-surface-2 text-text-muted";
|
||||||
|
|
||||||
|
return <span className={`text-[10px] px-2 py-0.5 rounded-full border ${color}`}>{text}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuotePriceLine({ quote, loading }: { quote: QuoteData | null; loading: boolean }) {
|
||||||
|
if (!quote) {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-xl bg-surface-2 px-3 py-3 text-sm text-text-muted">
|
||||||
|
{loading ? "正在读取最新行情证据..." : "暂无实时行情"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-end justify-between gap-3 mt-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-text-muted">现价</div>
|
||||||
|
<div className={`text-4xl font-bold font-mono tabular-nums leading-none ${quote.pct_chg > 0 ? "text-red-400" : quote.pct_chg < 0 ? "text-emerald-400" : "text-text-primary"}`}>
|
||||||
|
{quote.price.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[10px] text-text-muted">涨跌幅</div>
|
||||||
|
<div className={`text-lg font-bold font-mono tabular-nums ${quote.pct_chg > 0 ? "text-red-400" : quote.pct_chg < 0 ? "text-emerald-400" : "text-text-muted"}`}>
|
||||||
|
{quote.pct_chg > 0 ? "+" : ""}{quote.pct_chg.toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatRecallTag(tag: string): string {
|
function formatRecallTag(tag: string): string {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
sector_recall: "主线入选",
|
sector_recall: "主线入选",
|
||||||
@ -341,23 +369,16 @@ function formatRecallTag(tag: string): string {
|
|||||||
return labels[tag] ?? tag;
|
return labels[tag] ?? tag;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPrefilterDecision(decision?: string | null): string {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
priority: "优先研究",
|
|
||||||
watch: "保留观察",
|
|
||||||
ignore: "暂不处理",
|
|
||||||
};
|
|
||||||
return labels[decision ?? ""] ?? "暂无";
|
|
||||||
}
|
|
||||||
|
|
||||||
function EvidenceCard({
|
function EvidenceCard({
|
||||||
recommendation,
|
recommendation,
|
||||||
quote,
|
quote,
|
||||||
signals,
|
signals,
|
||||||
|
loading,
|
||||||
}: {
|
}: {
|
||||||
recommendation: RecommendationData | null | undefined;
|
recommendation: RecommendationData | null | undefined;
|
||||||
quote: QuoteData | null;
|
quote: QuoteData | null;
|
||||||
signals: StockSignals | null;
|
signals: StockSignals | null;
|
||||||
|
loading: boolean;
|
||||||
}) {
|
}) {
|
||||||
const reasons = recommendation?.reasons ?? [];
|
const reasons = recommendation?.reasons ?? [];
|
||||||
const evidenceChips = [
|
const evidenceChips = [
|
||||||
@ -369,12 +390,15 @@ function EvidenceCard({
|
|||||||
signals?.pullback_support ? "回踩支撑" : null,
|
signals?.pullback_support ? "回踩支撑" : null,
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
return (
|
return (
|
||||||
<div className="glass-card-static p-5">
|
<div className="glass-card-static p-4 md:p-5">
|
||||||
<SectionTitle title="作战依据" />
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<SectionTitle title="核心依据" />
|
||||||
|
{loading ? <span className="text-[10px] text-amber-400">行情同步中</span> : null}
|
||||||
|
</div>
|
||||||
{evidenceChips.length ? (
|
{evidenceChips.length ? (
|
||||||
<div className="flex flex-wrap gap-2 mt-3">
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
{evidenceChips.slice(0, 6).map((item) => (
|
{evidenceChips.slice(0, 6).map((item) => (
|
||||||
<span key={item} className="text-[11px] px-2.5 py-1 rounded-lg bg-surface-2 border border-border-subtle text-text-secondary">
|
<span key={item} className="text-[11px] px-2.5 py-1 rounded-md bg-surface-2 border border-border-subtle text-text-secondary">
|
||||||
{item}
|
{item}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@ -391,7 +415,7 @@ function EvidenceCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{recommendation?.risk_note ? (
|
{recommendation?.risk_note ? (
|
||||||
<div className="mt-4 rounded-xl bg-amber-500/[0.04] border border-amber-500/10 px-3 py-2 text-xs text-amber-400/80 leading-relaxed">
|
<div className="mt-4 rounded-lg bg-amber-500/[0.04] border border-amber-500/10 px-3 py-2 text-xs text-amber-400/80 leading-relaxed">
|
||||||
风险提示:{recommendation.risk_note}
|
风险提示:{recommendation.risk_note}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@ -447,10 +471,21 @@ function TrackingCard({ tracking }: { tracking: StockThesisResponse["latest_trac
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuoteSnapshot({ quote, evidenceLoaded }: { quote: QuoteData | null; evidenceLoaded: boolean }) {
|
function QuoteSnapshot({
|
||||||
|
quote,
|
||||||
|
evidenceLoading,
|
||||||
|
evidenceLoaded,
|
||||||
|
}: {
|
||||||
|
quote: QuoteData | null;
|
||||||
|
evidenceLoading: boolean;
|
||||||
|
evidenceLoaded: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="glass-card-static p-5">
|
<div className="glass-card-static p-5">
|
||||||
<SectionTitle title="盘面表现" />
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<SectionTitle title="盘口细节" />
|
||||||
|
<EvidenceStatus loading={evidenceLoading} loaded={evidenceLoaded} hasQuote={!!quote} />
|
||||||
|
</div>
|
||||||
{quote ? (
|
{quote ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-baseline gap-3 mt-3">
|
<div className="flex items-baseline gap-3 mt-3">
|
||||||
@ -471,7 +506,7 @@ function QuoteSnapshot({ quote, evidenceLoaded }: { quote: QuoteData | null; evi
|
|||||||
) : evidenceLoaded ? (
|
) : evidenceLoaded ? (
|
||||||
<div className="text-sm text-text-muted mt-3">暂无行情证据</div>
|
<div className="text-sm text-text-muted mt-3">暂无行情证据</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-text-muted mt-3">点击“加载行情证据”后查看</div>
|
<div className="text-sm text-text-muted mt-3">{evidenceLoading ? "正在读取行情证据..." : "等待行情同步"}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -480,10 +515,12 @@ function QuoteSnapshot({ quote, evidenceLoaded }: { quote: QuoteData | null; evi
|
|||||||
function SignalSnapshot({
|
function SignalSnapshot({
|
||||||
signals,
|
signals,
|
||||||
recScore,
|
recScore,
|
||||||
|
evidenceLoading,
|
||||||
evidenceLoaded,
|
evidenceLoaded,
|
||||||
}: {
|
}: {
|
||||||
signals: StockSignals | null;
|
signals: StockSignals | null;
|
||||||
recScore: RecScore | null;
|
recScore: RecScore | null;
|
||||||
|
evidenceLoading: boolean;
|
||||||
evidenceLoaded: boolean;
|
evidenceLoaded: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -506,7 +543,7 @@ function SignalSnapshot({
|
|||||||
) : evidenceLoaded ? (
|
) : evidenceLoaded ? (
|
||||||
<div className="text-sm text-text-muted mt-3">暂无技术信号</div>
|
<div className="text-sm text-text-muted mt-3">暂无技术信号</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-text-muted mt-3">点击“加载行情证据”后查看</div>
|
<div className="text-sm text-text-muted mt-3">{evidenceLoading ? "正在计算技术信号..." : "等待信号同步"}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -537,7 +574,7 @@ function CapitalFlowBreakdown({ flow }: { flow: FlowRecord }) {
|
|||||||
|
|
||||||
function ChartEmptyCard({ title, description }: { title: string; description: string }) {
|
function ChartEmptyCard({ title, description }: { title: string; description: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-bg-card rounded-xl p-4">
|
<div className="bg-bg-card rounded-xl p-4 border border-border-subtle/70">
|
||||||
<h2 className="text-sm font-medium text-text-secondary mb-2">{title}</h2>
|
<h2 className="text-sm font-medium text-text-secondary mb-2">{title}</h2>
|
||||||
<div className="h-56 flex items-center justify-center text-sm text-text-muted">{description}</div>
|
<div className="h-56 flex items-center justify-center text-sm text-text-muted">{description}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -546,8 +583,8 @@ function ChartEmptyCard({ title, description }: { title: string; description: st
|
|||||||
|
|
||||||
function DecisionPoint({ label, value }: { label: string; value: string }) {
|
function DecisionPoint({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl bg-surface-1/70 border border-border-subtle px-3 py-2.5">
|
<div className="rounded-lg bg-surface-1/70 border border-border-subtle px-3 py-2.5">
|
||||||
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">{label}</div>
|
<div className="text-[10px] text-text-muted tracking-wider font-semibold">{label}</div>
|
||||||
<div className="text-sm text-text-primary leading-relaxed mt-1">{value}</div>
|
<div className="text-sm text-text-primary leading-relaxed mt-1">{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -562,11 +599,18 @@ function PlanRow({ label, value }: { label: string; value: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MiniDataCell({ label, value }: { label: string; value: string | number }) {
|
function MiniDataCell({ label, value, tone }: { label: string; value: string | number; tone?: number | null }) {
|
||||||
|
const toneClass = tone == null
|
||||||
|
? "text-text-secondary"
|
||||||
|
: tone > 0
|
||||||
|
? "text-red-400"
|
||||||
|
: tone < 0
|
||||||
|
? "text-emerald-400"
|
||||||
|
: "text-text-secondary";
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl bg-surface-2 px-3 py-2">
|
<div className="rounded-lg bg-surface-2 px-3 py-2">
|
||||||
<div className="text-[10px] text-text-muted/60">{label}</div>
|
<div className="text-[10px] text-text-muted/60">{label}</div>
|
||||||
<div className="text-sm font-mono tabular-nums text-text-secondary mt-0.5">{value}</div>
|
<div className={`text-sm font-mono tabular-nums mt-0.5 ${toneClass}`}>{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -613,7 +657,7 @@ function FlowBar({ label, value, max }: { label: string; value: number; max: num
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SectionTitle({ title }: { title: string }) {
|
function SectionTitle({ title }: { title: string }) {
|
||||||
return <div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">{title}</div>;
|
return <div className="text-[10px] text-text-muted tracking-wider font-semibold">{title}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLifecycleLabel(status: string) {
|
function getLifecycleLabel(status: string) {
|
||||||
@ -626,7 +670,7 @@ function getLifecycleLabel(status: string) {
|
|||||||
expired: "到期复盘",
|
expired: "到期复盘",
|
||||||
invalidated: "已失效",
|
invalidated: "已失效",
|
||||||
};
|
};
|
||||||
return labels[status] ?? status;
|
return labels[status] ?? "观察";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActionPlanClass(actionPlan: string) {
|
function getActionPlanClass(actionPlan: string) {
|
||||||
@ -667,6 +711,8 @@ function signalTypeLabel(signalType?: string) {
|
|||||||
launch: "启动",
|
launch: "启动",
|
||||||
reversal: "反转",
|
reversal: "反转",
|
||||||
breakout_confirm: "突破确认",
|
breakout_confirm: "突破确认",
|
||||||
|
flow_momentum: "资金推动",
|
||||||
|
none: "观察",
|
||||||
};
|
};
|
||||||
return map[signalType || ""] ?? "观察";
|
return map[signalType || ""] ?? "观察";
|
||||||
}
|
}
|
||||||
@ -684,3 +730,11 @@ function positionComment(positionScore: number) {
|
|||||||
if (positionScore >= 50) return "位置中性";
|
if (positionScore >= 50) return "位置中性";
|
||||||
return "位置偏高,防追高";
|
return "位置偏高,防追高";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sameStockCode(left: string, right: string) {
|
||||||
|
return stripMarket(left) === stripMarket(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripMarket(value: string) {
|
||||||
|
return value.replace(/\.(SH|SZ|BJ)$/i, "").replace(/^(SH|SZ|BJ)/i, "");
|
||||||
|
}
|
||||||
|
|||||||
@ -34,6 +34,17 @@ function FireIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RadarIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12 20a8 8 0 1 0-8-8" />
|
||||||
|
<path d="M12 16a4 4 0 1 0-4-4" />
|
||||||
|
<path d="M12 12l7-7" />
|
||||||
|
<path d="M4 20h16" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StrategyIcon() {
|
function StrategyIcon() {
|
||||||
return (
|
return (
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
@ -117,6 +128,7 @@ export function SidebarNav() {
|
|||||||
<SideNavItem href="/dashboard" icon={<DashboardIcon />} label="今日作战" />
|
<SideNavItem href="/dashboard" icon={<DashboardIcon />} label="今日作战" />
|
||||||
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐池" />
|
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐池" />
|
||||||
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块主线" />
|
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块主线" />
|
||||||
|
<SideNavItem href="/sentiment" icon={<RadarIcon />} label="舆情雷达" />
|
||||||
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" />
|
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" />
|
||||||
<SideNavItem href="/chat" icon={<ChatIcon />} label="研究助手" />
|
<SideNavItem href="/chat" icon={<ChatIcon />} label="研究助手" />
|
||||||
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="个股诊断" />
|
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="个股诊断" />
|
||||||
@ -162,6 +174,9 @@ export function MobileBottomNav() {
|
|||||||
<MobileNavItem href="/chat" label="助手">
|
<MobileNavItem href="/chat" label="助手">
|
||||||
<ChatIcon />
|
<ChatIcon />
|
||||||
</MobileNavItem>
|
</MobileNavItem>
|
||||||
|
<MobileNavItem href="/sentiment" label="舆情">
|
||||||
|
<RadarIcon />
|
||||||
|
</MobileNavItem>
|
||||||
<MobileNavItem href="/watchlists" label="自选">
|
<MobileNavItem href="/watchlists" label="自选">
|
||||||
<WatchlistIcon />
|
<WatchlistIcon />
|
||||||
</MobileNavItem>
|
</MobileNavItem>
|
||||||
|
|||||||
@ -4,11 +4,10 @@ import type { RecommendationData } from "@/lib/api";
|
|||||||
|
|
||||||
export default function StockCard({ rec, compact = false }: { rec: RecommendationData; compact?: boolean }) {
|
export default function StockCard({ rec, compact = false }: { rec: RecommendationData; compact?: boolean }) {
|
||||||
const action = getActionMeta(rec.action_plan);
|
const action = getActionMeta(rec.action_plan);
|
||||||
const trigger = rec.trigger_condition ?? rec.entry_timing ?? rec.decision_trace?.headline ?? rec.prefilter_reason ?? rec.reasons?.[0] ?? "等待触发条件确认";
|
const trigger = rec.trigger_condition ?? rec.entry_timing ?? rec.decision_trace?.headline ?? rec.reasons?.[0] ?? "等待触发条件确认";
|
||||||
const risk = rec.invalidation_condition ?? rec.risk_note ?? "暂无明确失效条件";
|
const risk = rec.invalidation_condition ?? rec.risk_note ?? "暂无明确失效条件";
|
||||||
const thesis = rec.decision_trace?.headline ?? rec.reasons?.[0] ?? rec.focus_points?.[0] ?? "等待更多盘面证据";
|
const thesis = rec.decision_trace?.headline ?? rec.reasons?.[0] ?? rec.focus_points?.[0] ?? "等待更多盘面证据";
|
||||||
const conviction = rec.llm_score != null ? Math.round(rec.llm_score) : null;
|
const chips = buildChips(rec).slice(0, compact ? 3 : 5);
|
||||||
const chips = buildChips(rec, conviction).slice(0, compact ? 3 : 5);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
@ -85,7 +84,7 @@ function getActionMeta(actionPlan?: string | null) {
|
|||||||
return { label: "观察", className: "border-border-subtle bg-surface-2 text-text-muted" };
|
return { label: "观察", className: "border-border-subtle bg-surface-2 text-text-muted" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChips(rec: RecommendationData, conviction: number | null) {
|
function buildChips(rec: RecommendationData) {
|
||||||
const recallLabels: Record<string, string> = {
|
const recallLabels: Record<string, string> = {
|
||||||
sector_recall: "主线入选",
|
sector_recall: "主线入选",
|
||||||
trend_scan: "趋势入选",
|
trend_scan: "趋势入选",
|
||||||
@ -100,7 +99,7 @@ function buildChips(rec: RecommendationData, conviction: number | null) {
|
|||||||
return [
|
return [
|
||||||
rec.entry_signal_type ? signalTypeLabel(rec.entry_signal_type) : null,
|
rec.entry_signal_type ? signalTypeLabel(rec.entry_signal_type) : null,
|
||||||
rec.review_after_days ? `${rec.review_after_days}日复盘` : null,
|
rec.review_after_days ? `${rec.review_after_days}日复盘` : null,
|
||||||
conviction != null ? `把握 ${conviction}/10` : null,
|
rec.score != null ? `规则分 ${Math.round(rec.score)}` : null,
|
||||||
...(rec.recall_tags ?? []).map((tag) => recallLabels[tag] ?? tag),
|
...(rec.recall_tags ?? []).map((tag) => recallLabels[tag] ?? tag),
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -242,6 +242,45 @@ export interface SectorData {
|
|||||||
catalyst_reasons?: string[];
|
catalyst_reasons?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CatalystNewsItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
source: string;
|
||||||
|
url: string;
|
||||||
|
published_at: string | null;
|
||||||
|
status: "pending" | "analyzed" | "skipped" | "failed" | string;
|
||||||
|
catalyst_id: number | null;
|
||||||
|
error: string;
|
||||||
|
created_at: string;
|
||||||
|
analyzed_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalystEvent {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
source: string;
|
||||||
|
url: string;
|
||||||
|
published_at: string | null;
|
||||||
|
catalyst_type: "policy" | "industry" | "event" | "earnings" | "announcement" | "news" | string;
|
||||||
|
strength: number;
|
||||||
|
freshness: number;
|
||||||
|
confidence: number;
|
||||||
|
raw_text: string;
|
||||||
|
llm_reason: string;
|
||||||
|
created_at: string;
|
||||||
|
themes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeCatalystScore {
|
||||||
|
theme_id: string;
|
||||||
|
theme_name: string;
|
||||||
|
catalyst_score: number;
|
||||||
|
catalyst_count: number;
|
||||||
|
top_reasons: string[];
|
||||||
|
generated_by: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LatestResult {
|
export interface LatestResult {
|
||||||
market_temperature: MarketTemperatureData | null;
|
market_temperature: MarketTemperatureData | null;
|
||||||
recommendations: RecommendationData[];
|
recommendations: RecommendationData[];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user