feat: AI分析第三档——海外期货章节、报告分卡片渲染、红绿着色、期号;LLM改流式+分段生成绕开网关120s超时
This commit is contained in:
@@ -84,3 +84,9 @@
|
||||
- 截断续写:报告长导致 finish_reason=length 时,拼接已有内容并向 messages 追加"继续"指令让模型续写(最多3次),truncated 仅在续写后仍截断时为 true;call_llm 读超时 300s(续写携带全部上下文)
|
||||
- 快照入库防护:_capture_market_snapshot 校验 indices 非空且涨跌统计不全 0,fuyao 失败时跳过入库,防止空快照污染环比
|
||||
- 调试注意:独立脚本跑 backend 代码必须显式 `load_dotenv("/path/to/repo/.env")`——fuyao_apikey 在仓库根目录 .env(uvicorn 靠 --env-file 参数加载),backend/.env 只有 MX keys;且 stdin 脚本里 load_dotenv() 无参调用会因 frame 断言报错,须显式传路径
|
||||
|
||||
- 第三档呈现:报告页按 `## ` 二级标题拆分为多张卡片渲染(`splitReport`),首卡含 # 标题+定调引用块;表格单元格数字按 A股惯例红涨绿跌(`colorizeChildren` 只给带 +/- 号的数字着色);标题栏显示"总第 N 期"(`issue_number`,latest 接口用 COUNT(trade_date<=) 子查询计算)
|
||||
- 海外指数/国内期货:`fetch_global_markets` 走 push2delay 的 ulist.np(海外 secid `100.NDX` 等)+ clist(期货 fs `m:8/113/142/114/115`,只取名称含"主连/主力合约"且排除"次主连",按成交额降序取前12);接入 dashboard `globalMarkets` 字段
|
||||
- ⚠️ openteam 网关对 LLM 单请求有约 120s 硬超时(超时返回 502 或空 SSE),GLM reasoning 长报告一次生成必死。解法:`call_llm` 全流式(SSE)+ `collect_ai_analysis` 分三段生成(REPORT_PARTS,每段约1200-1600字,各重试3次带退避,失败占位不阻塞);工具轮拿到数据后立即 break 进分段(再问一轮只会空转120s);`thinking:{type:disabled}` 参数网关返回400不可用
|
||||
- 工具结果必须瘦身:`get_active_core_stocks` 只保留出现次数前25只+题材前5(全量145KB会撑爆上下文);get_market_dashboard 全量约13KB可接受
|
||||
- glm-5.3-flash 空返回特征:SSE 200 但只有 reasoning_content 无 content/tool_calls/finish_reason(可能流满120s被掐),按空轮次处理重试即可
|
||||
|
||||
@@ -43,8 +43,11 @@ def _can_trigger(trade_date: str) -> tuple[bool, str]:
|
||||
async def get_latest_report():
|
||||
conn = get_connection()
|
||||
try:
|
||||
# issue_number:按报告日期序数作为总期号
|
||||
row = conn.execute(
|
||||
"SELECT * FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 1"
|
||||
"""SELECT r.*,
|
||||
(SELECT COUNT(*) FROM ai_reports WHERE trade_date <= r.trade_date) AS issue_number
|
||||
FROM ai_reports r ORDER BY r.trade_date DESC, r.id DESC LIMIT 1"""
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="暂无分析报告")
|
||||
|
||||
@@ -165,6 +165,7 @@ async def _build_dashboard() -> dict:
|
||||
skyrocket_data,
|
||||
sector_flow_data,
|
||||
margin_data,
|
||||
global_markets_data,
|
||||
) = await asyncio.gather(
|
||||
fuyao_client.hot_stock_list("day"),
|
||||
fuyao_client.dragon_tiger_list("all"),
|
||||
@@ -173,6 +174,7 @@ async def _build_dashboard() -> dict:
|
||||
fuyao_client.skyrocket_list("day"),
|
||||
market_extra.fetch_sector_fund_flow(),
|
||||
market_extra.fetch_margin_summary(),
|
||||
market_extra.fetch_global_markets(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
except Exception:
|
||||
@@ -183,6 +185,7 @@ async def _build_dashboard() -> dict:
|
||||
skyrocket_data = {}
|
||||
sector_flow_data = None
|
||||
margin_data = None
|
||||
global_markets_data = None
|
||||
|
||||
# ── 解析指数 ──
|
||||
indices = []
|
||||
@@ -565,6 +568,7 @@ async def _build_dashboard() -> dict:
|
||||
"sectorStrength": sector_strength[:31],
|
||||
"conceptStrength": concept_strength[:10],
|
||||
"sectorFundFlow": sector_flow_data if isinstance(sector_flow_data, dict) else None,
|
||||
"globalMarkets": global_markets_data if isinstance(global_markets_data, dict) else None,
|
||||
"events": events,
|
||||
"limitLadder": limit_ladder,
|
||||
"updateTime": datetime.now(BJT).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
|
||||
+160
-79
@@ -7,6 +7,7 @@
|
||||
4. 保存报告到数据库
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import traceback
|
||||
@@ -29,7 +30,7 @@ SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中
|
||||
- 风险提示,每次推荐都需说明风险点
|
||||
|
||||
可用工具:
|
||||
- get_market_dashboard: 获取市场整体数据(指数/涨跌统计/市场温度/连板梯队/行业强度/板块资金流/两融/事件情报)
|
||||
- get_market_dashboard: 获取市场整体数据(指数/涨跌统计/市场温度/连板梯队/行业强度/板块资金流/两融/海外指数与国内期货/事件情报)
|
||||
- get_theme_history: 获取指定日期的题材涨幅排行
|
||||
- get_active_core_stocks: 获取核心股追踪数据(10日涨幅矩阵+所属题材)
|
||||
- get_stock_quote: 获取个股实时行情
|
||||
@@ -48,7 +49,7 @@ DAILY_ANALYSIS_PROMPT = """请对 {trade_date} 的A股市场进行收盘分析
|
||||
{prev_snapshot_section}
|
||||
|
||||
请先调用以下工具获取数据:
|
||||
1. get_market_dashboard - 获取市场整体数据(含涨跌统计、市场温度、连板梯队 limitLadder、板块资金流 sectorFundFlow、两融)
|
||||
1. get_market_dashboard - 获取市场整体数据(含涨跌统计、市场温度、连板梯队 limitLadder、板块资金流 sectorFundFlow、两融、海外与期货 globalMarkets)
|
||||
2. get_theme_history(date="{trade_date}") - 获取今日题材涨幅
|
||||
3. get_active_core_stocks - 获取核心股数据
|
||||
4. get_news(limit=30) - 获取今日财经快讯
|
||||
@@ -64,59 +65,101 @@ DAILY_ANALYSIS_PROMPT = """请对 {trade_date} 的A股市场进行收盘分析
|
||||
|
||||
4. 板块资金面必须引用 get_market_dashboard 返回的 sectorFundFlow:行业主力净流入TOP3、净流出TOP3、概念净流入TOP3(单位亿元),结合题材分析说明资金动向。
|
||||
|
||||
5. 重要消息面必须基于 get_news 返回的快讯整理:挑5-8条对次日盘面影响最大的消息,每条格式为"【分类】一句话新闻 —— 一句影响解读"(分类用:宏观/政策/行业/公司/海外);快讯中若没有某方面的重要消息,如实说明,严禁编造工具中不存在的新闻。
|
||||
5. 海外市场与国内期货必须引用 get_market_dashboard 返回的 globalMarkets:overseas 为海外主要指数(纳斯达克/道琼斯/标普500/恒生/日经/富时),futures 为国内期货主力合约(按成交额降序,已含价格与涨跌幅);点评与A股关联度高的品种(股指期货、原油、贵金属、黑色系),数据缺失则如实说明。
|
||||
|
||||
6. 适当使用表格展示数据对比。
|
||||
6. 重要消息面必须基于 get_news 返回的快讯整理:挑5-8条对次日盘面影响最大的消息,每条格式为"【分类】一句话新闻 —— 一句影响解读"(分类用:宏观/政策/行业/公司/海外);快讯中若没有某方面的重要消息,如实说明,严禁编造工具中不存在的新闻。
|
||||
|
||||
然后基于数据生成报告,结构如下:
|
||||
7. 适当使用表格展示数据对比。
|
||||
|
||||
## 一、市场总览
|
||||
- 主要指数表现(上证、深证、创业板、科创50)
|
||||
- 涨跌家数统计(含涨停/跌停/炸板率,须环比)
|
||||
- 市场温度评估
|
||||
|
||||
## 二、题材热点分析
|
||||
- 今日涨幅前5题材
|
||||
- 持续活跃的题材
|
||||
- 新兴热点题材
|
||||
- 明显退潮的题材(警示)
|
||||
- 板块主力资金流(按格式规则4引用数据)
|
||||
|
||||
## 三、核心股追踪
|
||||
- 连板梯队分析(按格式规则3完整呈现)
|
||||
- 核心股表现
|
||||
- 龙头股辨识
|
||||
|
||||
## 四、关注方向
|
||||
- 明日值得关注的题材方向
|
||||
- 潜在的交易机会
|
||||
|
||||
## 五、下个交易日建议
|
||||
- 明日大盘预判(支撑/压力位)
|
||||
- 建议关注的题材方向(2-3个)
|
||||
- 建议关注的核心股(附理由)
|
||||
- 操作策略(仓位建议、买卖时机)
|
||||
- 需要规避的方向
|
||||
|
||||
## 六、重要消息面
|
||||
- 基于 get_news 快讯整理(按格式规则5)
|
||||
|
||||
## 七、风险提示
|
||||
- 需要警惕的风险因素
|
||||
- 操作建议
|
||||
然后基于数据生成报告。报告共八章,将由系统分三次调用完成,每次调用只负责其中一部分,具体写作指令由后续消息给出。
|
||||
|
||||
请用 Markdown 格式输出,适当使用表格展示数据对比。"""
|
||||
|
||||
# 分段生成指令:网关对单次 LLM 请求有约120s硬超时,整篇报告一次生成必被掐断,
|
||||
# 故拆为三段(每段约1200-1600字),各自独立调用后拼接
|
||||
REPORT_PARTS = [
|
||||
"""现在写报告的【第1部分】,只输出这一部分,直接输出 Markdown,不要任何开场白或说明:
|
||||
1. 以 `# {title} A股收盘分析报告` 一级标题开头
|
||||
2. 标题后第一行输出定调引用块,格式严格为:`> 今日定调:<80字内核心结论,含1-2个关键数字>`
|
||||
3. 写 `## 一、市场总览`(指数表格、涨跌统计须环比、市场温度)与 `## 二、题材热点分析`(涨幅前5、持续活跃、新兴热点、退潮警示、板块主力资金流TOP3)
|
||||
全文控制在1600字以内。""",
|
||||
"""现在写报告的【第2部分】,只输出这一部分,直接输出 Markdown,不要重复之前内容:
|
||||
- `## 三、核心股追踪`(连板梯队完整表格:层级/股票/涨停原因/封单,首板挑3-5只人气股点评;核心股表现;龙头辨识)
|
||||
- `## 四、关注方向`(明日题材方向、潜在交易机会)
|
||||
全文控制在1300字以内。""",
|
||||
"""现在写报告的【第3部分】,只输出这一部分,直接输出 Markdown,不要重复之前内容:
|
||||
- `## 五、下个交易日建议`(大盘预判、题材方向、核心股、仓位策略、规避方向)
|
||||
- `## 六、重要消息面`(5-8条,格式【分类】新闻——影响解读)
|
||||
- `## 七、海外市场与国内期货`(点评对次日A股的影响)
|
||||
- `## 八、风险提示`
|
||||
全文控制在1900字以内。""",
|
||||
]
|
||||
|
||||
# 报告头部的"今日定调"引用行,保存时提取为 summary
|
||||
_TONE_LINE_RE = re.compile(r"^>\s*今日定调[::]\s*(.+)$", re.MULTILINE)
|
||||
|
||||
|
||||
async def _consume_sse(resp: httpx.Response) -> dict:
|
||||
"""消费 OpenAI 兼容 SSE 流,拼装为与非流式响应相同的结构"""
|
||||
content_parts: list[str] = []
|
||||
finish_reason = ""
|
||||
usage: dict = {}
|
||||
# tool_calls 按 index 拼装(流式下 arguments 分片到达)
|
||||
tool_acc: dict[int, dict] = {}
|
||||
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data = line[5:].strip()
|
||||
if not data or data == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if chunk.get("usage"):
|
||||
usage = chunk["usage"]
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta") or {}
|
||||
if delta.get("content"):
|
||||
content_parts.append(delta["content"])
|
||||
for tc in delta.get("tool_calls") or []:
|
||||
idx = tc.get("index", 0)
|
||||
slot = tool_acc.setdefault(idx, {"id": "", "type": "function",
|
||||
"function": {"name": "", "arguments": ""}})
|
||||
if tc.get("id"):
|
||||
slot["id"] = tc["id"]
|
||||
fn = tc.get("function") or {}
|
||||
if fn.get("name"):
|
||||
slot["function"]["name"] += fn["name"]
|
||||
if fn.get("arguments"):
|
||||
slot["function"]["arguments"] += fn["arguments"]
|
||||
if choices[0].get("finish_reason"):
|
||||
finish_reason = choices[0]["finish_reason"]
|
||||
|
||||
message: dict = {"role": "assistant", "content": "".join(content_parts) or None}
|
||||
if tool_acc:
|
||||
message["tool_calls"] = [
|
||||
{"id": tool_acc[i]["id"], "type": "function",
|
||||
"function": tool_acc[i]["function"]}
|
||||
for i in sorted(tool_acc)
|
||||
]
|
||||
return {"choices": [{"message": message, "finish_reason": finish_reason}], "usage": usage}
|
||||
|
||||
|
||||
async def call_llm(messages: list, tools: list = None) -> dict:
|
||||
"""调用 OpenAI 兼容 API"""
|
||||
"""调用 OpenAI 兼容 API(流式)。
|
||||
|
||||
必须用 stream:网关对非流式请求有约120s的代理超时,长生成会被 502 掐断;
|
||||
流式下字节持续到达不会被判定超时。返回结构与非流式一致。
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
payload = {
|
||||
"model": AI_MODEL,
|
||||
"messages": messages,
|
||||
"stream": True,
|
||||
}
|
||||
if AI_MAX_TOKENS is not None:
|
||||
payload["max_tokens"] = AI_MAX_TOKENS
|
||||
@@ -126,17 +169,40 @@ async def call_llm(messages: list, tools: list = None) -> dict:
|
||||
payload["tools"] = tools
|
||||
payload["tool_choice"] = "auto"
|
||||
|
||||
resp = await client.post(
|
||||
f"{AI_API_BASE}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {AI_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
timeout=300, # 续写调用携带全部上下文且输出很长,需要较宽的读超时
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
max_attempts = 4
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{AI_API_BASE}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {AI_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
},
|
||||
json=payload,
|
||||
timeout=600,
|
||||
) as resp:
|
||||
if resp.status_code == 429 and attempt < max_attempts:
|
||||
wait = min(30 * attempt, 90)
|
||||
print(f"[ai-service] LLM 429 限流,{wait}s 后重试(第 {attempt}/{max_attempts - 1} 次)")
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
if resp.status_code >= 500 and attempt < max_attempts:
|
||||
print(f"[ai-service] LLM {resp.status_code},{min(15 * attempt, 60)}s 后重试")
|
||||
await asyncio.sleep(min(15 * attempt, 60))
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return await _consume_sse(resp)
|
||||
except httpx.TransportError as e:
|
||||
# 网络层错误(超时/断连)也值得重试
|
||||
if attempt < max_attempts:
|
||||
wait = min(15 * attempt, 60)
|
||||
print(f"[ai-service] LLM 网络错误({type(e).__name__}),{wait}s 后重试")
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
raise RuntimeError("LLM 调用重试次数耗尽")
|
||||
|
||||
|
||||
async def collect_ai_analysis(trade_date: str) -> dict:
|
||||
@@ -162,12 +228,11 @@ async def collect_ai_analysis(trade_date: str) -> dict:
|
||||
)}
|
||||
]
|
||||
|
||||
# ── 阶段一:工具轮(只获取数据,模型若直接开写报告则丢弃,由阶段二重写) ──
|
||||
tools_used = []
|
||||
total_tokens = 0
|
||||
final_content = ""
|
||||
was_truncated = False
|
||||
continuations = 0
|
||||
max_rounds = 30 # 安全上限,正常分析约 3-8 轮
|
||||
max_rounds = 8
|
||||
|
||||
for i in range(max_rounds):
|
||||
response = await call_llm(messages, tools=TOOLS)
|
||||
@@ -175,44 +240,60 @@ async def collect_ai_analysis(trade_date: str) -> dict:
|
||||
|
||||
choice = response["choices"][0]
|
||||
message = choice["message"]
|
||||
messages.append(message)
|
||||
|
||||
finish_reason = choice.get("finish_reason", "")
|
||||
print(f"[ai-service] round {i}: finish={finish_reason}, "
|
||||
print(f"[ai-service] tool round {i}: finish={finish_reason}, "
|
||||
f"content_len={len(message.get('content') or '')}, "
|
||||
f"tool_calls={len(message.get('tool_calls') or [])}")
|
||||
|
||||
if finish_reason == "stop":
|
||||
final_content += message.get("content") or ""
|
||||
break
|
||||
|
||||
if finish_reason == "length":
|
||||
# 单次输出上限截断:拼接已有内容并让模型续写(最多3次)
|
||||
final_content += message.get("content") or ""
|
||||
if continuations < 3:
|
||||
continuations += 1
|
||||
print(f"[ai-service] 响应被截断,第 {continuations} 次续写")
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": "报告输出被截断了。请从截断处无缝续写剩余内容:直接接着写,不要重复已输出的部分,也不要重新输出标题。",
|
||||
})
|
||||
continue
|
||||
was_truncated = True
|
||||
print("[ai-service] 警告:多次续写后仍被截断")
|
||||
break
|
||||
|
||||
if finish_reason == "tool_calls":
|
||||
for tool_call in message.get("tool_calls", []):
|
||||
if finish_reason == "tool_calls" and message.get("tool_calls"):
|
||||
messages.append(message)
|
||||
for tool_call in message["tool_calls"]:
|
||||
func_name = tool_call["function"]["name"]
|
||||
func_args = json.loads(tool_call["function"]["arguments"])
|
||||
tools_used.append(func_name)
|
||||
|
||||
result = await execute_tool(func_name, func_args)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call["id"],
|
||||
"content": result
|
||||
})
|
||||
break # 工具已齐,立即进入分段写作(再问一轮模型只会空转120s)
|
||||
|
||||
# 非工具轮(模型直接开写/空返回):只要有工具结果就直接进入分段写作;
|
||||
# 一轮工具都没拿到则重试
|
||||
if tools_used:
|
||||
break
|
||||
print(f"[ai-service] 未获取到工具数据,重试({i + 1}/{max_rounds})")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
if not tools_used:
|
||||
raise RuntimeError("工具数据获取失败,无法生成报告")
|
||||
|
||||
# ── 阶段二:分段生成报告(绕开网关单请求约120s硬超时) ──
|
||||
final_content = ""
|
||||
for part_idx, part_prompt in enumerate(REPORT_PARTS):
|
||||
part_content = ""
|
||||
for attempt, backoff in ((1, 0), (2, 10), (3, 30)):
|
||||
if backoff:
|
||||
await asyncio.sleep(backoff)
|
||||
part_messages = messages + [{
|
||||
"role": "user",
|
||||
"content": part_prompt.format(title=trade_date) if part_idx == 0 else part_prompt,
|
||||
}]
|
||||
response = await call_llm(part_messages)
|
||||
total_tokens += response.get("usage", {}).get("total_tokens", 0)
|
||||
choice = response["choices"][0]
|
||||
part_content = choice["message"].get("content") or ""
|
||||
finish = choice.get("finish_reason", "")
|
||||
print(f"[ai-service] part {part_idx + 1} attempt {attempt}: finish={finish}, len={len(part_content)}")
|
||||
if part_content and finish in ("stop", "length"):
|
||||
break
|
||||
print(f"[ai-service] part {part_idx + 1} 生成异常,重试")
|
||||
if not part_content:
|
||||
was_truncated = True
|
||||
print(f"[ai-service] 警告:part {part_idx + 1} 三次尝试均失败")
|
||||
part_content = f"\n\n> (第{part_idx + 1}部分生成失败,请稍后重新生成)\n"
|
||||
final_content += (final_content and "\n\n" or "") + part_content
|
||||
|
||||
summary = _extract_summary(final_content)
|
||||
report_id = _save_report(trade_date, final_content, summary, tools_used, total_tokens)
|
||||
|
||||
@@ -16,7 +16,7 @@ TOOLS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_market_dashboard",
|
||||
"description": "获取A股市场看板数据,包含主要指数行情、全市场涨跌统计(涨跌家数/涨停/跌停/炸板率/成交额)、市场温度评分与竞价信号、行业强度榜、概念热度、完整连板梯队(limitLadder字段,含涨停原因与封单金额)、事件情报(热门股/龙虎榜/飙升/异动)",
|
||||
"description": "获取A股市场看板数据,包含主要指数行情、全市场涨跌统计(涨跌家数/涨停/跌停/炸板率/成交额)、市场温度评分与竞价信号、行业强度榜、概念热度、板块主力资金流、两融余额、海外主要指数与国内期货主力合约(globalMarkets字段)、完整连板梯队(limitLadder字段,含涨停原因与封单金额)、事件情报(热门股/龙虎榜/飙升/异动)",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []}
|
||||
}
|
||||
},
|
||||
@@ -184,9 +184,9 @@ async def _get_active_core_stocks() -> str:
|
||||
if s["lastAppear"] is None or r["trade_date"] > s["lastAppear"]:
|
||||
s["lastAppear"] = r["trade_date"]
|
||||
|
||||
stocks = list(stock_days.values())
|
||||
stocks.sort(key=lambda x: x.get("lastAppear") or "", reverse=True)
|
||||
stocks.sort(key=lambda x: -x["appearCount"])
|
||||
# 只保留最近10日中出现次数最多的前25只(全量可达145KB,会把上下文撑爆)
|
||||
stocks = sorted(stock_days.values(), key=lambda x: -x["appearCount"])[:25]
|
||||
keep_codes = {s["stockCode"] for s in stocks}
|
||||
|
||||
themes_rows = conn.execute(
|
||||
f"""SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes
|
||||
@@ -195,10 +195,12 @@ async def _get_active_core_stocks() -> str:
|
||||
).fetchall()
|
||||
themes_by_stock = {}
|
||||
for t in themes_rows:
|
||||
if t["stock_code"] not in keep_codes:
|
||||
continue
|
||||
per = themes_by_stock.setdefault(t["stock_code"], {})
|
||||
per.setdefault(t["theme_code"], {"theme_code": t["theme_code"], "theme_name": t["theme_name"]})
|
||||
for s in stocks:
|
||||
s["themes"] = list(themes_by_stock.get(s["stockCode"], {}).values())
|
||||
s["themes"] = list(themes_by_stock.get(s["stockCode"], {}).values())[:5]
|
||||
|
||||
latest = dates[-1] if dates else None
|
||||
for s in stocks:
|
||||
|
||||
@@ -86,6 +86,73 @@ async def _fetch_flow_boards(client: httpx.AsyncClient, fs: str, po: int, pz: in
|
||||
return boards
|
||||
|
||||
|
||||
_GLOBAL_INDICES = "100.NDX,100.DJIA,100.SPX,100.HSI,100.N225,100.FTSE"
|
||||
_FUT_MARKETS = ("m:8", "m:113", "m:142", "m:114", "m:115") # 中金所/上期所/上期能源/大商所/郑商所
|
||||
|
||||
|
||||
async def fetch_global_markets() -> dict | None:
|
||||
"""海外主要指数 + 国内期货主力合约;失败返回 None"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT, headers={"User-Agent": _UA}) as client:
|
||||
overseas, futures = await asyncio.gather(
|
||||
_fetch_overseas_indices(client),
|
||||
_fetch_futures_main(client),
|
||||
)
|
||||
if not overseas and not futures:
|
||||
return None
|
||||
return {"overseas": overseas, "futures": futures}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_overseas_indices(client: httpx.AsyncClient) -> list[dict]:
|
||||
resp = await client.get(
|
||||
"https://push2delay.eastmoney.com/api/qt/ulist.np/get",
|
||||
params={"secids": _GLOBAL_INDICES, "fields": "f12,f14,f2,f3", "fltt": 2, "invt": 2},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
diff = (resp.json().get("data") or {}).get("diff") or []
|
||||
return [
|
||||
{"name": d.get("f14", ""), "price": d.get("f2"), "changePct": d.get("f3")}
|
||||
for d in diff
|
||||
]
|
||||
|
||||
|
||||
async def _fetch_futures_main(client: httpx.AsyncClient) -> list[dict]:
|
||||
"""国内期货主力合约(名称含"主连/主力合约"),按成交额降序取前12"""
|
||||
import re
|
||||
|
||||
results: list[dict] = []
|
||||
for fs in _FUT_MARKETS:
|
||||
try:
|
||||
resp = await client.get(
|
||||
"https://push2delay.eastmoney.com/api/qt/clist/get",
|
||||
params={"fid": "f6", "po": 1, "pz": 200, "pn": 1, "np": 1,
|
||||
"fltt": 2, "invt": 2, "fs": fs, "fields": "f12,f14,f2,f3,f6"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
diff = (resp.json().get("data") or {}).get("diff") or []
|
||||
except Exception:
|
||||
continue
|
||||
for d in diff:
|
||||
name = d.get("f14") or ""
|
||||
if "主连" not in name and "主力合约" not in name:
|
||||
continue
|
||||
if "次主连" in name: # 次主力合约,排除
|
||||
continue
|
||||
amount = d.get("f6")
|
||||
if not isinstance(amount, (int, float)):
|
||||
continue
|
||||
results.append({
|
||||
"name": re.sub(r"(主连|主力合约)$", "", name),
|
||||
"price": d.get("f2"),
|
||||
"changePct": d.get("f3"),
|
||||
"amountYi": round(amount / 1e8, 1),
|
||||
})
|
||||
results.sort(key=lambda x: -x["amountYi"])
|
||||
return results[:12]
|
||||
|
||||
|
||||
async def fetch_sector_fund_flow() -> dict | None:
|
||||
"""板块主力资金流排行:行业净流入/净流出 TOP6 + 概念净流入 TOP6;失败返回 None"""
|
||||
try:
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface AiReport {
|
||||
model: string;
|
||||
tokens_used: number;
|
||||
generation_count?: number;
|
||||
issue_number?: number;
|
||||
created_at: string;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
+109
-35
@@ -4,6 +4,7 @@ import { ArrowLeft, Clock, Loader2, AlertCircle, Wrench, Calendar } from "lucide
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
import { Mermaid } from "../components/Mermaid";
|
||||
import { fetchAiLatestReport } from "../lib/ai-analysis-api";
|
||||
import { Card, CardContent } from "../components/ui/card";
|
||||
@@ -12,6 +13,51 @@ export const Route = createFileRoute("/ai-analysis")({
|
||||
component: AiAnalysisPage,
|
||||
});
|
||||
|
||||
/** A股惯例红涨绿跌:只给带显式 +/- 号的数字着色(+2.3% / -1.2亿 / +56万...),无符号数字语义不明保持默认 */
|
||||
const SIGNED_NUM_RE = /([+-]\d+(?:\.\d+)?(?:%|亿|万亿|万)?)/g;
|
||||
|
||||
function colorizeText(text: string): React.ReactNode[] {
|
||||
return text.split(SIGNED_NUM_RE).map((part, i) => {
|
||||
if (part.startsWith("+") || part.startsWith("-")) {
|
||||
const val = parseFloat(part);
|
||||
if (!Number.isNaN(val) && val !== 0) {
|
||||
if (val > 0) return <span key={i} className="text-red-500">{part}</span>;
|
||||
return <span key={i} className="text-green-600">{part}</span>;
|
||||
}
|
||||
}
|
||||
return <React.Fragment key={i}>{part}</React.Fragment>;
|
||||
});
|
||||
}
|
||||
|
||||
function colorizeChildren(children: React.ReactNode): React.ReactNode {
|
||||
return React.Children.map(children, (child) => {
|
||||
if (typeof child === "string") return colorizeText(child);
|
||||
if (React.isValidElement(child)) {
|
||||
const kids = (child.props as { children?: React.ReactNode }).children;
|
||||
if (kids != null) {
|
||||
return React.cloneElement(
|
||||
child as React.ReactElement<{ children?: React.ReactNode }>,
|
||||
{},
|
||||
colorizeChildren(kids),
|
||||
);
|
||||
}
|
||||
}
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
/** 按 "## " 二级标题拆分报告为多张卡片;首段(# 标题 + 定调引用块)单独一张 */
|
||||
function splitReport(content: string): { intro: string; sections: { title: string; body: string }[] } {
|
||||
const parts = content.split(/\n(?=## )/);
|
||||
const sections = parts.slice(1).map((p) => {
|
||||
const nl = p.indexOf("\n");
|
||||
const title = (nl === -1 ? p : p.slice(0, nl)).replace(/^##\s*/, "").trim();
|
||||
const body = nl === -1 ? "" : p.slice(nl + 1).trim();
|
||||
return { title, body };
|
||||
});
|
||||
return { intro: parts[0].trim(), sections };
|
||||
}
|
||||
|
||||
function AiAnalysisPage() {
|
||||
const { data: report, isLoading, isError } = useQuery({
|
||||
queryKey: ["ai-report-latest"],
|
||||
@@ -19,6 +65,30 @@ function AiAnalysisPage() {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const mdComponents: Components = {
|
||||
table({ children, ...props }) {
|
||||
return (
|
||||
<div className="overflow-x-auto -mx-3 sm:mx-0 px-3 sm:px-0">
|
||||
<table {...props}>{children}</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
td({ children, ...props }) {
|
||||
return <td {...props}>{colorizeChildren(children)}</td>;
|
||||
},
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
if (match && match[1] === "mermaid") {
|
||||
return <Mermaid chart={String(children).replace(/\n$/, "")} />;
|
||||
}
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="max-w-4xl mx-auto px-3 sm:px-4 py-4 sm:py-6">
|
||||
@@ -30,6 +100,9 @@ function AiAnalysisPage() {
|
||||
<h1 className="text-lg sm:text-xl md:text-2xl font-bold flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-primary" />
|
||||
AI 收盘分析
|
||||
{report?.issue_number ? (
|
||||
<span className="text-xs font-normal text-muted-foreground">总第 {report.issue_number} 期</span>
|
||||
) : null}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -81,44 +154,45 @@ function AiAnalysisPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Report Content */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-3 sm:p-4 md:p-6 ai-report-content">
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
table({ children, ...props }) {
|
||||
return (
|
||||
<div className="overflow-x-auto -mx-3 sm:mx-0 px-3 sm:px-0">
|
||||
<table {...props}>{children}</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
if (match && match[1] === "mermaid") {
|
||||
return <Mermaid chart={String(children).replace(/\n$/, "")} />;
|
||||
}
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{report.content}
|
||||
</Markdown>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<p className="text-xs text-muted-foreground/60 text-center mt-4 sm:mt-6">
|
||||
本报告由 AI 生成,仅供参考,不构成投资建议
|
||||
</p>
|
||||
{/* 报告正文:按 ## 章节分卡片渲染 */}
|
||||
{(() => {
|
||||
const { intro, sections } = splitReport(report.content);
|
||||
return (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{intro && (
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-3 sm:p-4 md:p-6 ai-report-content">
|
||||
<Markdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||||
{intro}
|
||||
</Markdown>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{sections.map((sec) => (
|
||||
<Card key={sec.title} className="bg-card border-border overflow-hidden">
|
||||
<div className="px-3 sm:px-4 md:px-6 pt-3 sm:pt-4 pb-2 sm:pb-3 border-b border-border/60">
|
||||
<h2 className="text-base sm:text-lg font-semibold">{sec.title}</h2>
|
||||
</div>
|
||||
<CardContent className="p-3 sm:p-4 md:p-6 ai-report-content">
|
||||
<Markdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||||
{sec.body}
|
||||
</Markdown>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Disclaimer */}
|
||||
<p className="text-xs text-muted-foreground/60 text-center mt-4 sm:mt-6">
|
||||
本报告由 AI 生成,仅供参考,不构成投资建议
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AiAnalysisPage;
|
||||
|
||||
Reference in New Issue
Block a user