Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8d36f32cf | ||
|
|
1d46ad7a08 | ||
|
|
311b9f0d7e | ||
|
|
0fb2f8d3a5 | ||
|
|
375d196fef | ||
|
|
9a72f0bbf6 |
@@ -123,3 +123,6 @@ tmp/
|
||||
/.core.hmbtNy
|
||||
/.core.dump
|
||||
.v2-demo-backup/
|
||||
|
||||
# 后端运行时数据(本地启动生成:SQLite 库、管理密码等)
|
||||
backend/data/
|
||||
|
||||
@@ -55,8 +55,9 @@
|
||||
- 生成分享链接时复用已有短链,避免每次生成新链接
|
||||
- 全页面适配移动端:响应式字体、间距、布局断点(sm/md/lg)
|
||||
- K线图历史数据获取失败时直接报错,禁止使用模拟数据降级(避免"刷新数据变化"问题)
|
||||
- K线天数逻辑:默认3个月(90天),自选日期到现在超过3个月则从自选日期开始
|
||||
- K线图使用 recharts Brush 组件实现移动端缩放和滑动查看
|
||||
- K线天数逻辑:日K一次拉取近365天(供拖动回看),默认视口只展示最近90个自然日(3个月),向左拖动查看更早数据;分钟K一次拉取320根,铺满展示
|
||||
- K线图基于 TradingView Lightweight Charts v5 实现(蜡烛/折线 + MA/MACD/RSI 副图);拖动/缩放查看数据,切换指标或显示方式保留当前视口(仅在数据集变化时重置)
|
||||
- K线图时间格式自定义:时间轴刻度 年→`YYYY`、月→`YYYY-MM`、日→`MM-DD`,十字光标→`YYYY-MM-DD`(`tickMarkFormatter` + `localization.timeFormatter`);格式化必须用 UTC 取值(`getUTCFullYear` 等),因为时间戳按"北京时间墙钟视作 UTC"存储,用本地时区方法会错位一天
|
||||
- 板块标记:688开头=科创(红)、300/301开头=创业(紫)、920/8/4开头=北交(橙),主板不显示标签;标记位置:搜索候选、详情页标题、集合卡片股票列表、分享页股票卡片标题
|
||||
- 详情页右上角外部跳转按钮:①"东方财富" `https://wap.eastmoney.com/quote/stock/{market}.{code}.html?appfenxiang=1`,market映射 688→6/60→1/其他→0;②"金十数据" `https://search.jin10.com/?keyword={股票名称URL编码}`(按名称搜索金十资讯)
|
||||
- 详情页资金流向模块:展示近30日资金流向分析,包含:
|
||||
@@ -70,3 +71,22 @@
|
||||
- 资金流向数据获取失败时,前端通过 `fundFlowError` 状态显示错误信息,便于排查问题
|
||||
- ❌ 东方财富 API(push2his.eastmoney.com)在 Edge Function 环境被拒绝访问(peer closed connection),主力净流入数据无法获取,显示为 "-"
|
||||
- ✅ 替代方案:使用腾讯实时行情API的外盘(索引7)和内盘(索引8)数据计算净主动买入额 = (外盘 - 内盘) × 当前价 × 100(单位:元)
|
||||
## 每日 AI 分析(backend FastAPI,15:10 自动触发)
|
||||
|
||||
- 报告结构约定:正文标题(#)后第一行必须是引用块 `> 今日定调:<80字内核心结论+关键数字>`;保存时由 `_extract_summary()` 正则提取进 `summary` 字段,前端顶部高亮展示,缺失时退回正文截断
|
||||
- 环比数据:`collect_ai_analysis` 每次运行先采集当日盘面快照(指数+涨跌统计 JSON)写入 `daily_market_stats` 表(UNIQUE trade_date,upsert),再读上一交易日快照格式化为 prompt 中的"环比数据段";首跑无昨日数据时 AI 须如实标注"暂无昨日基准"
|
||||
- 连板梯队:`get_market_dashboard` 返回 `limitLadder` 字段(完整版,2连板以上全量+首板前8),涨停原因/封单金额来自涨停池按 thscode 匹配;看板 events 里的天梯仍只取每层前2(保持 UI 精简),两处用途不同不要合并
|
||||
- tokens 口径:`ai_reports.tokens_used` 只记"当次生成"消耗(约7万/份);同日重新生成走 UPSERT,`generation_count` 自增、`updated_at` 刷新、`created_at` 保留首次生成时间;表有 UNIQUE(trade_date, report_type),禁止改回 INSERT OR REPLACE 之外还要注意别用 lastrowid(UPSERT 更新时不可靠,须回查 id)
|
||||
- 旧库补列用 init_db 里的 try/except ALTER TABLE 轻量迁移(CREATE TABLE IF NOT EXISTS 不会更新旧表结构)
|
||||
|
||||
- 补充数据源(services/market_extra.py):①财经快讯 get_news=新浪7x24 zhibo.sina.cn(feed.list.rich_text);②板块主力资金流=东财 push2 的 clist 接口(f62 主力净额),**必须用 push2delay.eastmoney.com 镜像**——push2 对部分客户端 TLS 指纹拦截(peer closed),查询串保持字面量 `+` 号;③两融=datacenter-web 的 RPTA_RZRQ_LSHJ(T+1 披露),两融余额=RZYE+RQYE
|
||||
- AI 报告重要消息面规则:必须基于 get_news 快讯,5-8条,格式【宏观/政策/行业/公司/海外】新闻——影响解读,禁止编造;板块资金面规则:必须引用 sectorFundFlow 的行业净流入/流出TOP3+概念TOP3
|
||||
- 截断续写:报告长导致 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被掐),按空轮次处理重试即可
|
||||
|
||||
@@ -82,9 +82,19 @@ CREATE TABLE IF NOT EXISTS ai_reports (
|
||||
tools_used TEXT,
|
||||
model TEXT NOT NULL,
|
||||
tokens_used INTEGER,
|
||||
generation_count INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
updated_at TEXT,
|
||||
UNIQUE(trade_date, report_type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_market_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
@@ -107,6 +117,16 @@ def init_db():
|
||||
conn = get_connection()
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
|
||||
# 轻量迁移:为已存在的旧表补列(CREATE TABLE IF NOT EXISTS 不会更新旧表结构)
|
||||
for alter_sql in (
|
||||
"ALTER TABLE ai_reports ADD COLUMN generation_count INTEGER NOT NULL DEFAULT 1",
|
||||
"ALTER TABLE ai_reports ADD COLUMN updated_at TEXT",
|
||||
):
|
||||
try:
|
||||
conn.execute(alter_sql)
|
||||
except sqlite3.OperationalError:
|
||||
pass # 列已存在
|
||||
|
||||
# 清理过期缓存
|
||||
from services.cache import clean_expired
|
||||
clean_expired()
|
||||
|
||||
@@ -143,7 +143,8 @@ async def admin_list_reports(request: Request):
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, created_at "
|
||||
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, "
|
||||
"generation_count, created_at, updated_at "
|
||||
"FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50"
|
||||
).fetchall()
|
||||
items = []
|
||||
|
||||
@@ -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="暂无分析报告")
|
||||
@@ -60,7 +63,8 @@ async def list_reports():
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, created_at "
|
||||
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, "
|
||||
"generation_count, created_at, updated_at "
|
||||
"FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50"
|
||||
).fetchall()
|
||||
items = []
|
||||
|
||||
@@ -17,7 +17,7 @@ from datetime import datetime, timezone, timedelta
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from services import fuyao_client
|
||||
from services import fuyao_client, market_extra
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -163,12 +163,18 @@ async def _build_dashboard() -> dict:
|
||||
anomaly_data,
|
||||
limit_ladder_data,
|
||||
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"),
|
||||
fuyao_client.anomaly_analysis_list(["SHARP_RISE", "RAPID_RALLY", "LIMIT_UP"]),
|
||||
fuyao_client.limit_up_ladder(),
|
||||
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:
|
||||
@@ -177,6 +183,9 @@ async def _build_dashboard() -> dict:
|
||||
anomaly_data = {}
|
||||
limit_ladder_data = {}
|
||||
skyrocket_data = {}
|
||||
sector_flow_data = None
|
||||
margin_data = None
|
||||
global_markets_data = None
|
||||
|
||||
# ── 解析指数 ──
|
||||
indices = []
|
||||
@@ -291,6 +300,12 @@ async def _build_dashboard() -> dict:
|
||||
"auctionSignal": auction_signal,
|
||||
}
|
||||
|
||||
# 两融余额(交易所 T+1 披露),供看板展示、AI 分析与次日环比快照使用
|
||||
if isinstance(margin_data, dict):
|
||||
market_stats["marginBalanceYi"] = margin_data.get("balanceYi")
|
||||
market_stats["marginChangeYi"] = margin_data.get("changeYi")
|
||||
market_stats["marginDate"] = margin_data.get("date")
|
||||
|
||||
# ── 行业强度榜(使用行业指数真实涨幅) ──
|
||||
sector_strength = []
|
||||
industries = industry_catalog if isinstance(industry_catalog, list) else []
|
||||
@@ -485,13 +500,77 @@ async def _build_dashboard() -> dict:
|
||||
"detail": f"涨停 {limit_up_count} 跌停 {limit_down_count} 炸板 {break_count}",
|
||||
})
|
||||
|
||||
# ── 连板梯队(完整版,供 AI 分析用;上方 events 天梯只取前2保持看板精简) ──
|
||||
# 涨停池里带 limit_up_reason / seal_money,按代码匹配给梯队个股
|
||||
reason_by_code = {}
|
||||
if isinstance(limit_up_data, dict):
|
||||
for lu in limit_up_data.get("item", []) or []:
|
||||
code = lu.get("thscode", "")
|
||||
if code:
|
||||
reason_by_code[code] = {
|
||||
"reason": (lu.get("limit_up_reason") or "").strip(),
|
||||
"sealWan": round(_safe_float(lu.get("seal_money")) / 10000),
|
||||
}
|
||||
|
||||
_LADDER_LEVELS = [
|
||||
("seven_over", 7, "7连板+"),
|
||||
("six_board", 6, "6连板"),
|
||||
("five_board", 5, "5连板"),
|
||||
("four_board", 4, "4连板"),
|
||||
("three_board", 3, "3连板"),
|
||||
("two_board", 2, "2连板"),
|
||||
("first_board", 1, "首板"),
|
||||
]
|
||||
limit_ladder = []
|
||||
if isinstance(limit_ladder_data, dict):
|
||||
ladder_items = limit_ladder_data.get("item", [])
|
||||
if ladder_items:
|
||||
today_boards = ladder_items[0].get("boards", {}) or {}
|
||||
seen_keys = set()
|
||||
for key, board_num, label in _LADDER_LEVELS:
|
||||
seen_keys.add(key)
|
||||
# 首板数量多(几十家)只取前8家;2连板以上全量保留
|
||||
cap = 8 if board_num == 1 else None
|
||||
entries = today_boards.get(key, []) or []
|
||||
if cap is not None:
|
||||
entries = entries[:cap]
|
||||
for item in entries:
|
||||
code = item.get("thscode", "")
|
||||
extra = reason_by_code.get(code, {})
|
||||
limit_ladder.append({
|
||||
"board": board_num,
|
||||
"label": label,
|
||||
"name": item.get("name", ""),
|
||||
"code": code,
|
||||
"reason": extra.get("reason", ""),
|
||||
"sealWan": extra.get("sealWan"),
|
||||
})
|
||||
# 兜底:天梯返回了未知层级 key 时也带上(跳过已处理的已知 key)
|
||||
for key, entries in today_boards.items():
|
||||
if key in seen_keys:
|
||||
continue
|
||||
for item in entries or []:
|
||||
code = item.get("thscode", "")
|
||||
extra = reason_by_code.get(code, {})
|
||||
limit_ladder.append({
|
||||
"board": _safe_float(item.get("board_num")) or 1,
|
||||
"label": f"{int(_safe_float(item.get('board_num')) or 1)}连板",
|
||||
"name": item.get("name", ""),
|
||||
"code": code,
|
||||
"reason": extra.get("reason", ""),
|
||||
"sealWan": extra.get("sealWan"),
|
||||
})
|
||||
|
||||
# ── 组装结果 ──
|
||||
result = {
|
||||
"indices": indices,
|
||||
"marketStats": market_stats,
|
||||
"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"),
|
||||
}
|
||||
|
||||
|
||||
+326
-65
@@ -3,10 +3,15 @@
|
||||
负责:
|
||||
1. 调用 OpenAI 兼容 API 进行分析
|
||||
2. Function Calling 循环(AI 可主动获取数据)
|
||||
3. 保存报告到数据库
|
||||
3. 采集当日盘面快照(供次日环比)
|
||||
4. 保存报告到数据库
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import traceback
|
||||
|
||||
import httpx
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
@@ -25,11 +30,12 @@ SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中
|
||||
- 风险提示,每次推荐都需说明风险点
|
||||
|
||||
可用工具:
|
||||
- get_market_dashboard: 获取市场整体数据(指数/涨跌统计/行业强度/事件情报/市场温度)
|
||||
- get_market_dashboard: 获取市场整体数据(指数/涨跌统计/市场温度/连板梯队/行业强度/板块资金流/两融/海外指数与国内期货/事件情报)
|
||||
- get_theme_history: 获取指定日期的题材涨幅排行
|
||||
- get_active_core_stocks: 获取核心股追踪数据(10日涨幅矩阵+所属题材)
|
||||
- get_stock_quote: 获取个股实时行情
|
||||
- get_fund_flow: 获取个股资金流向
|
||||
- get_news: 获取财经快讯(新浪7x24,用于重要消息面)
|
||||
|
||||
重要规则:
|
||||
1. 你必须先调用工具获取数据,然后基于数据进行分析
|
||||
@@ -40,53 +46,120 @@ SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中
|
||||
DAILY_ANALYSIS_PROMPT = """请对 {trade_date} 的A股市场进行收盘分析,生成一份完整的分析报告。
|
||||
|
||||
{prev_report_section}
|
||||
{prev_snapshot_section}
|
||||
|
||||
请先调用以下工具获取数据:
|
||||
1. get_market_dashboard - 获取市场整体数据
|
||||
1. get_market_dashboard - 获取市场整体数据(含涨跌统计、市场温度、连板梯队 limitLadder、板块资金流 sectorFundFlow、两融、海外与期货 globalMarkets)
|
||||
2. get_theme_history(date="{trade_date}") - 获取今日题材涨幅
|
||||
3. get_active_core_stocks - 获取核心股数据
|
||||
4. get_news(limit=30) - 获取今日财经快讯
|
||||
|
||||
然后基于数据生成报告,结构如下:
|
||||
生成报告时必须遵守以下格式规则:
|
||||
|
||||
## 一、市场总览
|
||||
- 主要指数表现(上证、深证、创业板)
|
||||
- 涨跌家数统计
|
||||
- 市场温度评估
|
||||
1. 报告标题(# 一级标题)之后的第一行,必须是一个引用块"定调摘要",格式严格为:
|
||||
> 今日定调:<一句话核心结论,不超过80字,必须包含1-2个关键数字(如成交额、涨停家数、市场温度)>
|
||||
|
||||
## 二、题材热点分析
|
||||
- 今日涨幅前5题材
|
||||
- 持续活跃的题材
|
||||
- 新兴热点题材
|
||||
2. 量能与情绪类数字必须给环比:若上方提供了"前一交易日盘面数据快照",成交额、涨跌家数、涨停数、两融余额、市场温度等在与昨日对比后表述(如"成交额2.05万亿,较昨日缩量约700亿");没有昨日快照则如实说明"暂无昨日数据"。
|
||||
|
||||
## 三、核心股追踪
|
||||
- 连板股分析
|
||||
- 核心股表现
|
||||
- 龙头股辨识
|
||||
3. 连板梯队必须完整呈现 get_market_dashboard 返回的 limitLadder:从最高连板到2连板逐级列表格,每只标注涨停原因(reason字段)与封单金额(sealWan,单位万,为空则不写);首板只挑3-5只人气最高的点评。
|
||||
|
||||
## 四、关注方向
|
||||
- 明日值得关注的题材方向
|
||||
- 潜在的交易机会
|
||||
4. 板块资金面必须引用 get_market_dashboard 返回的 sectorFundFlow:行业主力净流入TOP3、净流出TOP3、概念净流入TOP3(单位亿元),结合题材分析说明资金动向。
|
||||
|
||||
## 五、下个交易日建议
|
||||
- 明日大盘预判(支撑/压力位)
|
||||
- 建议关注的题材方向(2-3个)
|
||||
- 建议关注的核心股(附理由)
|
||||
- 操作策略(仓位建议、买卖时机)
|
||||
- 需要规避的方向
|
||||
5. 海外市场与国内期货必须引用 get_market_dashboard 返回的 globalMarkets:overseas 为海外主要指数(纳斯达克/道琼斯/标普500/恒生/日经/富时),futures 为国内期货主力合约(按成交额降序,已含价格与涨跌幅);点评与A股关联度高的品种(股指期货、原油、贵金属、黑色系),数据缺失则如实说明。
|
||||
|
||||
## 六、风险提示
|
||||
- 需要警惕的风险因素
|
||||
- 操作建议
|
||||
6. 重要消息面必须基于 get_news 返回的快讯整理:挑5-8条对次日盘面影响最大的消息,每条格式为"【分类】一句话新闻 —— 一句影响解读"(分类用:宏观/政策/行业/公司/海外);快讯中若没有某方面的重要消息,如实说明,严禁编造工具中不存在的新闻。
|
||||
|
||||
7. 适当使用表格展示数据对比。
|
||||
|
||||
然后基于数据生成报告。报告共八章,将由系统分三次调用完成,每次调用只负责其中一部分,具体写作指令由后续消息给出。
|
||||
|
||||
请用 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
|
||||
@@ -96,17 +169,40 @@ async def call_llm(messages: list, tools: list = None) -> dict:
|
||||
payload["tools"] = tools
|
||||
payload["tool_choice"] = "auto"
|
||||
|
||||
resp = await client.post(
|
||||
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=120,
|
||||
)
|
||||
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 resp.json()
|
||||
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:
|
||||
@@ -119,19 +215,24 @@ async def collect_ai_analysis(trade_date: str) -> dict:
|
||||
{"id": int, "tokens_used": int, "tools_used": list}
|
||||
"""
|
||||
prev_report_section = _get_prev_report_section(trade_date)
|
||||
# 采集当日盘面快照(供次日环比),并读取上一交易日快照注入 prompt
|
||||
await _capture_market_snapshot(trade_date)
|
||||
prev_snapshot_section = _get_prev_snapshot_section(trade_date)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": DAILY_ANALYSIS_PROMPT.format(
|
||||
trade_date=trade_date,
|
||||
prev_report_section=prev_report_section
|
||||
prev_report_section=prev_report_section,
|
||||
prev_snapshot_section=prev_snapshot_section,
|
||||
)}
|
||||
]
|
||||
|
||||
# ── 阶段一:工具轮(只获取数据,模型若直接开写报告则丢弃,由阶段二重写) ──
|
||||
tools_used = []
|
||||
total_tokens = 0
|
||||
final_content = ""
|
||||
was_truncated = False
|
||||
max_rounds = 30 # 安全上限,正常分析约 3-8 轮
|
||||
max_rounds = 8
|
||||
|
||||
for i in range(max_rounds):
|
||||
response = await call_llm(messages, tools=TOOLS)
|
||||
@@ -139,52 +240,208 @@ 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] 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":
|
||||
was_truncated = True
|
||||
final_content = message.get("content") or ""
|
||||
if not final_content:
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "assistant" and msg.get("content"):
|
||||
final_content = msg["content"]
|
||||
break
|
||||
print(f"[ai-service] 警告:响应被截断 (finish_reason=length)")
|
||||
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)
|
||||
|
||||
summary = final_content[:200].replace("\n", " ") if final_content else ""
|
||||
# 非工具轮(模型直接开写/空返回):只要有工具结果就直接进入分段写作;
|
||||
# 一轮工具都没拿到则重试
|
||||
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)
|
||||
|
||||
return {"id": report_id, "tokens_used": total_tokens, "tools_used": tools_used, "truncated": was_truncated}
|
||||
|
||||
|
||||
def _save_report(trade_date: str, content: str, summary: str, tools_used: list, tokens_used: int) -> int:
|
||||
"""保存报告到数据库"""
|
||||
def _extract_summary(content: str) -> str:
|
||||
"""提取报告头部的"今日定调"引用行作为摘要;缺失时退回正文截断"""
|
||||
match = _TONE_LINE_RE.search(content or "")
|
||||
if match:
|
||||
text = match.group(1).strip()
|
||||
if text:
|
||||
return text[:200]
|
||||
return (content or "")[:200].replace("\n", " ")
|
||||
|
||||
|
||||
def _num(v) -> str:
|
||||
"""快照数值安全转字符串"""
|
||||
if isinstance(v, float):
|
||||
return f"{v:g}"
|
||||
return str(v if v is not None else "-")
|
||||
|
||||
|
||||
def _fmt_amount(v) -> str:
|
||||
"""成交额(元)→ 万亿/亿 可读格式"""
|
||||
try:
|
||||
v = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return "-"
|
||||
if v >= 1e12:
|
||||
return f"{v / 1e12:.2f}万亿"
|
||||
if v >= 1e8:
|
||||
return f"{v / 1e8:.0f}亿"
|
||||
return f"{v:.0f}元"
|
||||
|
||||
|
||||
def _fmt_index(idx: dict) -> str:
|
||||
if not idx:
|
||||
return "-"
|
||||
try:
|
||||
pct = round(float(idx.get("changePct", 0)), 2)
|
||||
except (TypeError, ValueError):
|
||||
pct = 0
|
||||
sign = "+" if pct >= 0 else ""
|
||||
return f"{idx.get('price', '-')}({sign}{pct}%)"
|
||||
|
||||
|
||||
def _fmt_temperature(v) -> str:
|
||||
"""温度可能是 dict(score/label/factors),取分数与标签"""
|
||||
if isinstance(v, dict):
|
||||
score = v.get("score")
|
||||
label = v.get("label") or ""
|
||||
return f"{score}分{('(' + label + ')') if label else ''}"
|
||||
return _num(v)
|
||||
|
||||
|
||||
async def _capture_market_snapshot(trade_date: str) -> bool:
|
||||
"""采集当日盘面快照入库(指数+涨跌统计),供次日分析做环比"""
|
||||
try:
|
||||
from routes.market_dashboard import _build_dashboard
|
||||
data = await _build_dashboard()
|
||||
stats = data.get("marketStats") or {}
|
||||
# 数据有效性校验:fuyao 拉取失败时涨跌统计全 0,空快照会污染次日环比
|
||||
if not data.get("indices") or (stats.get("upCount", 0) + stats.get("downCount", 0) == 0):
|
||||
print(f"[ai-service] 盘面数据无效,跳过快照入库 {trade_date}")
|
||||
return False
|
||||
payload = json.dumps(
|
||||
{"indices": data.get("indices", []), "marketStats": stats},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"""INSERT OR REPLACE INTO ai_reports
|
||||
(trade_date, report_type, title, content, summary, tools_used, model, tokens_used)
|
||||
VALUES (?, 'daily', ?, ?, ?, ?, ?, ?)""",
|
||||
conn.execute(
|
||||
"""INSERT INTO daily_market_stats (trade_date, payload) VALUES (?, ?)
|
||||
ON CONFLICT(trade_date) DO UPDATE SET payload = excluded.payload""",
|
||||
(trade_date, payload),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return True
|
||||
except Exception:
|
||||
print(f"[ai-service] 市场快照采集失败 {trade_date}:")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
def _get_prev_snapshot_section(trade_date: str) -> str:
|
||||
"""读取上一交易日盘面快照,格式化为 prompt 中的环比数据段"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT trade_date, payload FROM daily_market_stats WHERE trade_date < ? ORDER BY trade_date DESC LIMIT 1",
|
||||
(trade_date,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return ""
|
||||
try:
|
||||
snap = json.loads(row["payload"])
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
stats = snap.get("marketStats") or {}
|
||||
indices = {i.get("name"): i for i in (snap.get("indices") or []) if isinstance(i, dict)}
|
||||
idx_line = "、".join(
|
||||
f"{name} {_fmt_index(indices.get(name))}"
|
||||
for name in ("上证指数", "深证成指", "创业板指", "科创50")
|
||||
)
|
||||
margin_line = ""
|
||||
if stats.get("marginBalanceYi"):
|
||||
change = stats.get("marginChangeYi")
|
||||
change_txt = ""
|
||||
if change is not None:
|
||||
sign = "+" if float(change) >= 0 else ""
|
||||
change_txt = f"(较前一日 {sign}{_num(change)}亿)"
|
||||
margin_line = f"\n- 两融余额:{_num(stats.get('marginBalanceYi'))}亿{change_txt},数据日期 {stats.get('marginDate') or '-'}(T+1)"
|
||||
return f"""以下是前一交易日({row["trade_date"]})的盘面数据快照,报告中的量能与情绪数字必须给出与它的环比对比:
|
||||
|
||||
- 两市成交额:{_fmt_amount(stats.get("totalTurnover"))}
|
||||
- 上涨/下跌/平盘:{_num(stats.get("upCount"))}/{_num(stats.get("downCount"))}/{_num(stats.get("flatCount"))},涨停 {_num(stats.get("limitUp"))} 家、跌停 {_num(stats.get("limitDown"))} 家、炸板 {_num(stats.get("limitBreak"))} 家(炸板率 {_num(stats.get("breakRate"))}%)
|
||||
- 强势/弱势股:{_num(stats.get("strongCount"))}/{_num(stats.get("weakCount"))},市场宽度 {_num(stats.get("marketBreadth"))}%
|
||||
- 市场温度:{_fmt_temperature(stats.get("temperature"))},竞价信号:{stats.get("auctionSignal") or "-"}{margin_line}
|
||||
- 指数收盘:{idx_line}
|
||||
|
||||
---
|
||||
"""
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _save_report(trade_date: str, content: str, summary: str, tools_used: list, tokens_used: int) -> int:
|
||||
"""保存报告到数据库(同日重生成:覆盖内容、generation_count+1、tokens 记当次消耗)"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT INTO ai_reports
|
||||
(trade_date, report_type, title, content, summary, tools_used, model, tokens_used, updated_at)
|
||||
VALUES (?, 'daily', ?, ?, ?, ?, ?, ?, datetime('now','localtime'))
|
||||
ON CONFLICT(trade_date, report_type) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
content = excluded.content,
|
||||
summary = excluded.summary,
|
||||
tools_used = excluded.tools_used,
|
||||
model = excluded.model,
|
||||
tokens_used = excluded.tokens_used,
|
||||
updated_at = excluded.updated_at,
|
||||
generation_count = ai_reports.generation_count + 1""",
|
||||
(
|
||||
trade_date,
|
||||
f"{trade_date} A股收盘分析",
|
||||
@@ -193,10 +450,14 @@ def _save_report(trade_date: str, content: str, summary: str, tools_used: list,
|
||||
json.dumps(tools_used),
|
||||
AI_MODEL,
|
||||
tokens_used,
|
||||
)
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.lastrowid
|
||||
row = conn.execute(
|
||||
"SELECT id FROM ai_reports WHERE trade_date = ? AND report_type = 'daily'",
|
||||
(trade_date,),
|
||||
).fetchone()
|
||||
return row["id"]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ TOOLS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_market_dashboard",
|
||||
"description": "获取A股市场看板数据,包含主要指数行情、涨跌统计、行业强度、概念热度、事件情报、市场温度评分",
|
||||
"description": "获取A股市场看板数据,包含主要指数行情、全市场涨跌统计(涨跌家数/涨停/跌停/炸板率/成交额)、市场温度评分与竞价信号、行业强度榜、概念热度、板块主力资金流、两融余额、海外主要指数与国内期货主力合约(globalMarkets字段)、完整连板梯队(limitLadder字段,含涨停原因与封单金额)、事件情报(热门股/龙虎榜/飙升/异动)",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []}
|
||||
}
|
||||
},
|
||||
@@ -71,6 +71,20 @@ TOOLS = [
|
||||
"required": ["code", "name"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_news",
|
||||
"description": "获取最近的财经快讯(新浪7x24,含宏观/行业/公司/海外动态),用于重要消息面梳理",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "获取条数,默认30,最大50"}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -92,12 +106,23 @@ async def execute_tool(tool_name: str, arguments: dict) -> str:
|
||||
arguments.get("name", ""),
|
||||
arguments.get("days", 30)
|
||||
)
|
||||
elif tool_name == "get_news":
|
||||
return await _get_news(arguments.get("limit", 30))
|
||||
else:
|
||||
return json.dumps({"error": f"未知工具: {tool_name}"})
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
async def _get_news(limit: int = 30) -> str:
|
||||
"""获取财经快讯"""
|
||||
from services.market_extra import fetch_news
|
||||
news = await fetch_news(limit)
|
||||
if not news:
|
||||
return json.dumps({"error": "快讯获取失败或暂无数据"}, ensure_ascii=False)
|
||||
return json.dumps({"count": len(news), "items": news}, ensure_ascii=False)
|
||||
|
||||
|
||||
async def _get_market_dashboard() -> str:
|
||||
"""获取市场看板数据"""
|
||||
from routes.market_dashboard import _build_dashboard
|
||||
@@ -159,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
|
||||
@@ -170,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:
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""市场补充数据源(供 AI 分析与看板扩展)
|
||||
|
||||
- fetch_news: 新浪财经 7x24 快讯
|
||||
- fetch_sector_fund_flow: 东方财富板块主力资金流排行(行业/概念)
|
||||
- fetch_margin_summary: 东方财富两融余额汇总(T+1 数据)
|
||||
|
||||
均为公开接口,失败时返回 []/None,不阻塞主流程。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
_TIMEOUT = 10.0
|
||||
_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
|
||||
|
||||
|
||||
async def fetch_news(limit: int = 30) -> list[dict]:
|
||||
"""新浪财经 7x24 快讯,返回 [{time: 'MM-DD HH:MM', content}];失败返回 []"""
|
||||
try:
|
||||
limit = max(1, min(int(limit or 30), 50))
|
||||
except (TypeError, ValueError):
|
||||
limit = 30
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT, headers={"User-Agent": _UA}) as client:
|
||||
resp = await client.get(
|
||||
"https://zhibo.sina.com.cn/api/zhibo/feed",
|
||||
params={"page": 1, "page_size": limit, "zhibo_id": 152, "tag_id": 0},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
items = resp.json()["result"]["data"]["feed"]["list"]
|
||||
news = []
|
||||
for it in items or []:
|
||||
text = (it.get("rich_text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
news.append({
|
||||
"time": (it.get("create_time") or "")[5:16], # 'MM-DD HH:MM'
|
||||
"content": text[:300],
|
||||
})
|
||||
return news
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
_FLOW_HOSTS = (
|
||||
# push2 对部分客户端有 TLS 指纹拦截(peer closed),delay 镜像同接口且稳定
|
||||
"https://push2delay.eastmoney.com",
|
||||
"https://push2.eastmoney.com",
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_flow_boards(client: httpx.AsyncClient, fs: str, po: int, pz: int) -> list[dict]:
|
||||
"""拉取一类板块的主力净流入排行。po=1 降序(净流入最多),po=0 升序(净流出最多)
|
||||
|
||||
查询串保持字面量 + 号(与东财网页请求一致),逐 host 尝试。
|
||||
"""
|
||||
qs = (f"/api/qt/clist/get?fid=f62&po={po}&pz={pz}&pn=1&np=1"
|
||||
f"&fltt=2&invt=2&fs={fs}&fields=f12,f14,f62,f184")
|
||||
last_err: Exception | None = None
|
||||
for host in _FLOW_HOSTS:
|
||||
try:
|
||||
resp = await client.get(host + qs)
|
||||
resp.raise_for_status()
|
||||
diff = (resp.json().get("data") or {}).get("diff") or []
|
||||
break
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
diff = []
|
||||
else:
|
||||
raise ConnectionError(f"板块资金流全部数据源失败: {last_err}")
|
||||
if isinstance(diff, dict): # 兼容旧版 {index: item} 结构
|
||||
diff = list(diff.values())
|
||||
boards = []
|
||||
for d in diff:
|
||||
amt = d.get("f62")
|
||||
if not isinstance(amt, (int, float)):
|
||||
continue
|
||||
boards.append({
|
||||
"code": d.get("f12", ""),
|
||||
"name": d.get("f14", ""),
|
||||
"mainNet": round(amt / 1e8, 1), # 亿元
|
||||
"mainNetPct": d.get("f184"), # 主力净占比 %
|
||||
})
|
||||
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:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT, headers={"User-Agent": _UA}) as client:
|
||||
industry_in, industry_out, concept_in = await asyncio.gather(
|
||||
_fetch_flow_boards(client, "m:90+t:2", 1, 6),
|
||||
_fetch_flow_boards(client, "m:90+t:2", 0, 6),
|
||||
_fetch_flow_boards(client, "m:90+t:3", 1, 6),
|
||||
)
|
||||
return {
|
||||
"industryInflow": industry_in, # 主力净流入降序
|
||||
"industryOutflow": industry_out, # 升序(净流出最多在前)
|
||||
"conceptInflow": concept_in,
|
||||
"unit": "亿元",
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_margin_summary() -> dict | None:
|
||||
"""沪深北两融余额汇总(交易所 T+1 披露);失败返回 None"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT, headers={"User-Agent": _UA}) as client:
|
||||
resp = await client.get(
|
||||
"https://datacenter-web.eastmoney.com/api/data/v1/get",
|
||||
params={
|
||||
"reportName": "RPTA_RZRQ_LSHJ",
|
||||
"columns": "ALL",
|
||||
"source": "WEB",
|
||||
"sortColumns": "dim_date",
|
||||
"sortTypes": "-1",
|
||||
"pageSize": 2,
|
||||
"pageNumber": 1,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
rows = ((resp.json().get("result") or {}).get("data")) or []
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
def _balance(row: dict) -> float:
|
||||
return float(row.get("RZYE") or 0) + float(row.get("RQYE") or 0)
|
||||
|
||||
latest = rows[0]
|
||||
prev = rows[1] if len(rows) > 1 else None
|
||||
balance = _balance(latest)
|
||||
change = (balance - _balance(prev)) if prev else None
|
||||
return {
|
||||
"date": (latest.get("DIM_DATE") or "")[:10],
|
||||
"balanceYi": round(balance / 1e8), # 亿元
|
||||
"changeYi": round(change / 1e8) if change is not None else None,
|
||||
"rzjmeYi": round(float(latest.get("RZJME") or 0) / 1e8, 1), # 融资净买入
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
@@ -137,6 +137,22 @@ export function KLineCard({ code, addedAt, ready }: Props) {
|
||||
const displayData = chartPeriod === "d" ? dailyData : minuteData;
|
||||
const currentLoading = chartPeriod === "d" ? chartLoading : minuteLoading;
|
||||
|
||||
// 传给图表的数据引用需稳定(仅随 displayData 变化),
|
||||
// 否则切换指标/显示方式时 KLineChart 会把新数组当成新数据集重置视口
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
displayData.map((d) => ({
|
||||
time: d.dateMs,
|
||||
open: d.open,
|
||||
close: d.close,
|
||||
high: d.high,
|
||||
low: d.low,
|
||||
volume: d.volume,
|
||||
isAddedDate: d.isAddedDate,
|
||||
})),
|
||||
[displayData],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
|
||||
@@ -213,7 +229,7 @@ export function KLineCard({ code, addedAt, ready }: Props) {
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 md:px-6 pb-2 md:pb-6">
|
||||
{currentLoading ? (
|
||||
<div className="h-[320px] sm:h-[380px] md:h-[440px] w-full flex items-center justify-center">
|
||||
<div className="h-[390px] w-full flex items-center justify-center">
|
||||
<div className="flex items-center text-muted-foreground text-sm">
|
||||
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>
|
||||
K线数据加载中...
|
||||
@@ -221,18 +237,11 @@ export function KLineCard({ code, addedAt, ready }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<KLineChart
|
||||
data={displayData.map((d) => ({
|
||||
time: d.dateMs,
|
||||
open: d.open,
|
||||
close: d.close,
|
||||
high: d.high,
|
||||
low: d.low,
|
||||
volume: d.volume,
|
||||
isAddedDate: d.isAddedDate,
|
||||
}))}
|
||||
data={chartData}
|
||||
mode={chartMode}
|
||||
hasAddedDate={ready}
|
||||
indicators={indicators}
|
||||
defaultVisibleDays={chartPeriod === "d" ? 90 : undefined}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ColorType,
|
||||
CrosshairMode,
|
||||
LineStyle,
|
||||
TickMarkType,
|
||||
createSeriesMarkers,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
@@ -41,6 +42,8 @@ interface Props {
|
||||
mode: "line" | "candle";
|
||||
hasAddedDate?: boolean;
|
||||
indicators: IndicatorToggles;
|
||||
/** 默认只展示最近 N 个自然日(日线用),其余数据靠拖动查看;不传则铺满全部数据 */
|
||||
defaultVisibleDays?: number;
|
||||
}
|
||||
|
||||
// lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。
|
||||
@@ -61,9 +64,41 @@ const MACD_DIF = "#3b82f6";
|
||||
const MACD_DEA = "#f59e0b";
|
||||
const RSI_COLOR = "#a855f7";
|
||||
|
||||
export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
||||
// 时间统一按 UTC 取值格式化(kline-card 把北京时间墙钟视作 UTC 存储),
|
||||
// 用本地时区方法会导致日期错位一天
|
||||
function timeToDate(t: Time): Date {
|
||||
if (typeof t === "number") return new Date(t * 1000);
|
||||
if (typeof t === "string") return new Date(t.length === 10 ? `${t}T00:00:00Z` : t);
|
||||
return new Date(Date.UTC(t.year, t.month - 1, t.day));
|
||||
}
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/** 十字光标处的完整日期:2026-09-01 */
|
||||
function formatCrosshairTime(t: Time): string {
|
||||
const d = timeToDate(t);
|
||||
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
/** 底部时间轴刻度:年→2026、月→2026-09、日→09-01(替代默认的 "01 9月 '26") */
|
||||
function formatTickMark(t: Time, tickMarkType: TickMarkType): string {
|
||||
const d = timeToDate(t);
|
||||
if (tickMarkType === TickMarkType.Year) return `${d.getUTCFullYear()}`;
|
||||
if (tickMarkType === TickMarkType.Month) return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}`;
|
||||
return `${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
// 各 pane 固定高度:主图加高、成交量压低,指标副图居中
|
||||
const MAIN_PANE_H = 320;
|
||||
const VOL_PANE_H = 70;
|
||||
const IND_PANE_H = 95;
|
||||
|
||||
export function KLineChart({ data, mode, hasAddedDate, indicators, defaultVisibleDays }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
// 记录上次填充的数据集:仅数据集变化(首次加载/切换周期)时调整视口,
|
||||
// 指标开关/显示方式切换时保留用户拖动缩放后的位置
|
||||
const lastDataRef = useRef<KLineItem[] | null>(null);
|
||||
const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null);
|
||||
const volSeriesRef = useRef<ISeriesApi<"Histogram"> | null>(null);
|
||||
// 指标 series(重建用)
|
||||
@@ -73,8 +108,15 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
||||
|
||||
// 容器高度随指标 pane 数量增长,避免主图被压缩
|
||||
const extraPanes = [indicators.macd, indicators.rsi].filter(Boolean).length;
|
||||
// 固定图表高度:主图+成交量 300,每个指标副图 +95
|
||||
const chartHeight = 300 + extraPanes * 95;
|
||||
// 图表总高 = 各 pane 高度之和(主图 + 成交量 + 指标副图×N)
|
||||
const chartHeight = MAIN_PANE_H + VOL_PANE_H + extraPanes * IND_PANE_H;
|
||||
|
||||
// pane 顺序固定:0=主图 1=成交量 2+=指标副图;指标开关增删 pane 后重新应用高度
|
||||
function applyPaneHeights(chart: IChartApi) {
|
||||
chart.panes().forEach((pane, i) => {
|
||||
pane.setHeight(i === 0 ? MAIN_PANE_H : i === 1 ? VOL_PANE_H : IND_PANE_H);
|
||||
});
|
||||
}
|
||||
|
||||
// 创建图表(仅一次):pane0 主图 + pane1 成交量
|
||||
useEffect(() => {
|
||||
@@ -94,7 +136,12 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
||||
horzLines: { color: GRID, style: LineStyle.Dashed, visible: true },
|
||||
},
|
||||
rightPriceScale: { borderColor: GRID },
|
||||
timeScale: { borderColor: GRID, timeVisible: false },
|
||||
localization: { timeFormatter: formatCrosshairTime },
|
||||
timeScale: {
|
||||
borderColor: GRID,
|
||||
timeVisible: false,
|
||||
tickMarkFormatter: formatTickMark,
|
||||
},
|
||||
crosshair: { mode: CrosshairMode.Normal },
|
||||
});
|
||||
chartRef.current = chart;
|
||||
@@ -109,6 +156,7 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
||||
1,
|
||||
);
|
||||
volSeriesRef.current = vol;
|
||||
applyPaneHeights(chart);
|
||||
|
||||
return () => {
|
||||
chart.remove();
|
||||
@@ -118,6 +166,8 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
||||
maSeriesRef.current = [];
|
||||
macdSeriesRef.current = [];
|
||||
rsiSeriesRef.current = [];
|
||||
// 图表实例销毁后重置,重建时重新应用默认视口(StrictMode 重挂载同样生效)
|
||||
lastDataRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -223,6 +273,8 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
||||
);
|
||||
rsiSeriesRef.current.push(s);
|
||||
}
|
||||
|
||||
applyPaneHeights(chart);
|
||||
}
|
||||
|
||||
// 模式切换:重建价格 series + 指标
|
||||
@@ -310,8 +362,24 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
||||
createSeriesMarkers(ps, []);
|
||||
}
|
||||
|
||||
if (lastDataRef.current === data) return;
|
||||
lastDataRef.current = data;
|
||||
if (defaultVisibleDays) {
|
||||
// 默认视口只展示最近 N 个自然日,更早的数据靠向左拖动查看
|
||||
const toMs = (t: string | number) =>
|
||||
typeof t === "number" ? t * 1000 : new Date(t).getTime();
|
||||
const lastMs = toMs(data[data.length - 1].time);
|
||||
let fromIdx = data.findIndex(
|
||||
(d) => toMs(d.time) >= lastMs - defaultVisibleDays * 86400_000,
|
||||
);
|
||||
if (fromIdx < 0) fromIdx = 0;
|
||||
chart
|
||||
.timeScale()
|
||||
.setVisibleLogicalRange({ from: fromIdx, to: data.length - 1 + 2 });
|
||||
} else {
|
||||
chart.timeScale().fitContent();
|
||||
}
|
||||
}
|
||||
|
||||
// 颜色图例:根据启用的指标生成
|
||||
const legend: { color: string; label: string }[] = [];
|
||||
|
||||
@@ -14,7 +14,10 @@ export interface AiReport {
|
||||
toolsUsed: string[];
|
||||
model: string;
|
||||
tokens_used: number;
|
||||
generation_count?: number;
|
||||
issue_number?: number;
|
||||
created_at: string;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export async function fetchAiLatestReport(): Promise<AiReport> {
|
||||
|
||||
+117
-28
@@ -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>
|
||||
|
||||
@@ -53,9 +126,12 @@ function AiAnalysisPage() {
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 sm:gap-4 text-xs sm:text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{report.created_at}</span>
|
||||
<span className="truncate">{report.updated_at || report.created_at}</span>
|
||||
</span>
|
||||
<span>{report.tokens_used?.toLocaleString()} tokens</span>
|
||||
{(report.generation_count ?? 1) > 1 && (
|
||||
<span>第 {report.generation_count} 次生成</span>
|
||||
)}
|
||||
{report.toolsUsed && report.toolsUsed.length > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Wrench className="h-3.5 w-3.5 shrink-0" />
|
||||
@@ -66,44 +142,57 @@ function AiAnalysisPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Report Content */}
|
||||
{/* 今日定调摘要 */}
|
||||
{report.summary && (
|
||||
<div className="mb-3 sm:mb-4 rounded-lg border border-primary/30 bg-primary/5 px-3 sm:px-4 py-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="shrink-0 mt-0.5 rounded bg-primary/10 px-1.5 py-0.5 text-[11px] font-semibold text-primary">
|
||||
今日定调
|
||||
</span>
|
||||
<p className="text-sm leading-relaxed">{report.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 报告正文:按 ## 章节分卡片渲染 */}
|
||||
{(() => {
|
||||
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={{
|
||||
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 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