Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8d36f32cf | ||
|
|
1d46ad7a08 | ||
|
|
311b9f0d7e | ||
|
|
0fb2f8d3a5 | ||
|
|
375d196fef | ||
|
|
9a72f0bbf6 |
@@ -123,3 +123,6 @@ tmp/
|
|||||||
/.core.hmbtNy
|
/.core.hmbtNy
|
||||||
/.core.dump
|
/.core.dump
|
||||||
.v2-demo-backup/
|
.v2-demo-backup/
|
||||||
|
|
||||||
|
# 后端运行时数据(本地启动生成:SQLite 库、管理密码等)
|
||||||
|
backend/data/
|
||||||
|
|||||||
@@ -55,8 +55,9 @@
|
|||||||
- 生成分享链接时复用已有短链,避免每次生成新链接
|
- 生成分享链接时复用已有短链,避免每次生成新链接
|
||||||
- 全页面适配移动端:响应式字体、间距、布局断点(sm/md/lg)
|
- 全页面适配移动端:响应式字体、间距、布局断点(sm/md/lg)
|
||||||
- K线图历史数据获取失败时直接报错,禁止使用模拟数据降级(避免"刷新数据变化"问题)
|
- K线图历史数据获取失败时直接报错,禁止使用模拟数据降级(避免"刷新数据变化"问题)
|
||||||
- K线天数逻辑:默认3个月(90天),自选日期到现在超过3个月则从自选日期开始
|
- K线天数逻辑:日K一次拉取近365天(供拖动回看),默认视口只展示最近90个自然日(3个月),向左拖动查看更早数据;分钟K一次拉取320根,铺满展示
|
||||||
- K线图使用 recharts Brush 组件实现移动端缩放和滑动查看
|
- 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开头=北交(橙),主板不显示标签;标记位置:搜索候选、详情页标题、集合卡片股票列表、分享页股票卡片标题
|
- 板块标记: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编码}`(按名称搜索金十资讯)
|
- 详情页右上角外部跳转按钮:①"东方财富" `https://wap.eastmoney.com/quote/stock/{market}.{code}.html?appfenxiang=1`,market映射 688→6/60→1/其他→0;②"金十数据" `https://search.jin10.com/?keyword={股票名称URL编码}`(按名称搜索金十资讯)
|
||||||
- 详情页资金流向模块:展示近30日资金流向分析,包含:
|
- 详情页资金流向模块:展示近30日资金流向分析,包含:
|
||||||
@@ -70,3 +71,22 @@
|
|||||||
- 资金流向数据获取失败时,前端通过 `fundFlowError` 状态显示错误信息,便于排查问题
|
- 资金流向数据获取失败时,前端通过 `fundFlowError` 状态显示错误信息,便于排查问题
|
||||||
- ❌ 东方财富 API(push2his.eastmoney.com)在 Edge Function 环境被拒绝访问(peer closed connection),主力净流入数据无法获取,显示为 "-"
|
- ❌ 东方财富 API(push2his.eastmoney.com)在 Edge Function 环境被拒绝访问(peer closed connection),主力净流入数据无法获取,显示为 "-"
|
||||||
- ✅ 替代方案:使用腾讯实时行情API的外盘(索引7)和内盘(索引8)数据计算净主动买入额 = (外盘 - 内盘) × 当前价 × 100(单位:元)
|
- ✅ 替代方案:使用腾讯实时行情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,
|
tools_used TEXT,
|
||||||
model TEXT NOT NULL,
|
model TEXT NOT NULL,
|
||||||
tokens_used INTEGER,
|
tokens_used INTEGER,
|
||||||
|
generation_count INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||||
|
updated_at TEXT,
|
||||||
UNIQUE(trade_date, report_type)
|
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 = get_connection()
|
||||||
conn.executescript(SCHEMA_SQL)
|
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
|
from services.cache import clean_expired
|
||||||
clean_expired()
|
clean_expired()
|
||||||
|
|||||||
@@ -143,7 +143,8 @@ async def admin_list_reports(request: Request):
|
|||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
rows = conn.execute(
|
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"
|
"FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
items = []
|
items = []
|
||||||
|
|||||||
@@ -43,8 +43,11 @@ def _can_trigger(trade_date: str) -> tuple[bool, str]:
|
|||||||
async def get_latest_report():
|
async def get_latest_report():
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
|
# issue_number:按报告日期序数作为总期号
|
||||||
row = conn.execute(
|
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()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="暂无分析报告")
|
raise HTTPException(status_code=404, detail="暂无分析报告")
|
||||||
@@ -60,7 +63,8 @@ async def list_reports():
|
|||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
rows = conn.execute(
|
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"
|
"FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
items = []
|
items = []
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from datetime import datetime, timezone, timedelta
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from services import fuyao_client
|
from services import fuyao_client, market_extra
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -163,12 +163,18 @@ async def _build_dashboard() -> dict:
|
|||||||
anomaly_data,
|
anomaly_data,
|
||||||
limit_ladder_data,
|
limit_ladder_data,
|
||||||
skyrocket_data,
|
skyrocket_data,
|
||||||
|
sector_flow_data,
|
||||||
|
margin_data,
|
||||||
|
global_markets_data,
|
||||||
) = await asyncio.gather(
|
) = await asyncio.gather(
|
||||||
fuyao_client.hot_stock_list("day"),
|
fuyao_client.hot_stock_list("day"),
|
||||||
fuyao_client.dragon_tiger_list("all"),
|
fuyao_client.dragon_tiger_list("all"),
|
||||||
fuyao_client.anomaly_analysis_list(["SHARP_RISE", "RAPID_RALLY", "LIMIT_UP"]),
|
fuyao_client.anomaly_analysis_list(["SHARP_RISE", "RAPID_RALLY", "LIMIT_UP"]),
|
||||||
fuyao_client.limit_up_ladder(),
|
fuyao_client.limit_up_ladder(),
|
||||||
fuyao_client.skyrocket_list("day"),
|
fuyao_client.skyrocket_list("day"),
|
||||||
|
market_extra.fetch_sector_fund_flow(),
|
||||||
|
market_extra.fetch_margin_summary(),
|
||||||
|
market_extra.fetch_global_markets(),
|
||||||
return_exceptions=True,
|
return_exceptions=True,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -177,6 +183,9 @@ async def _build_dashboard() -> dict:
|
|||||||
anomaly_data = {}
|
anomaly_data = {}
|
||||||
limit_ladder_data = {}
|
limit_ladder_data = {}
|
||||||
skyrocket_data = {}
|
skyrocket_data = {}
|
||||||
|
sector_flow_data = None
|
||||||
|
margin_data = None
|
||||||
|
global_markets_data = None
|
||||||
|
|
||||||
# ── 解析指数 ──
|
# ── 解析指数 ──
|
||||||
indices = []
|
indices = []
|
||||||
@@ -291,6 +300,12 @@ async def _build_dashboard() -> dict:
|
|||||||
"auctionSignal": auction_signal,
|
"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 = []
|
sector_strength = []
|
||||||
industries = industry_catalog if isinstance(industry_catalog, list) else []
|
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}",
|
"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 = {
|
result = {
|
||||||
"indices": indices,
|
"indices": indices,
|
||||||
"marketStats": market_stats,
|
"marketStats": market_stats,
|
||||||
"sectorStrength": sector_strength[:31],
|
"sectorStrength": sector_strength[:31],
|
||||||
"conceptStrength": concept_strength[:10],
|
"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,
|
"events": events,
|
||||||
|
"limitLadder": limit_ladder,
|
||||||
"updateTime": datetime.now(BJT).strftime("%Y-%m-%d %H:%M:%S"),
|
"updateTime": datetime.now(BJT).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+333
-72
@@ -3,10 +3,15 @@
|
|||||||
负责:
|
负责:
|
||||||
1. 调用 OpenAI 兼容 API 进行分析
|
1. 调用 OpenAI 兼容 API 进行分析
|
||||||
2. Function Calling 循环(AI 可主动获取数据)
|
2. Function Calling 循环(AI 可主动获取数据)
|
||||||
3. 保存报告到数据库
|
3. 采集当日盘面快照(供次日环比)
|
||||||
|
4. 保存报告到数据库
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
import traceback
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
@@ -25,11 +30,12 @@ SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中
|
|||||||
- 风险提示,每次推荐都需说明风险点
|
- 风险提示,每次推荐都需说明风险点
|
||||||
|
|
||||||
可用工具:
|
可用工具:
|
||||||
- get_market_dashboard: 获取市场整体数据(指数/涨跌统计/行业强度/事件情报/市场温度)
|
- get_market_dashboard: 获取市场整体数据(指数/涨跌统计/市场温度/连板梯队/行业强度/板块资金流/两融/海外指数与国内期货/事件情报)
|
||||||
- get_theme_history: 获取指定日期的题材涨幅排行
|
- get_theme_history: 获取指定日期的题材涨幅排行
|
||||||
- get_active_core_stocks: 获取核心股追踪数据(10日涨幅矩阵+所属题材)
|
- get_active_core_stocks: 获取核心股追踪数据(10日涨幅矩阵+所属题材)
|
||||||
- get_stock_quote: 获取个股实时行情
|
- get_stock_quote: 获取个股实时行情
|
||||||
- get_fund_flow: 获取个股资金流向
|
- get_fund_flow: 获取个股资金流向
|
||||||
|
- get_news: 获取财经快讯(新浪7x24,用于重要消息面)
|
||||||
|
|
||||||
重要规则:
|
重要规则:
|
||||||
1. 你必须先调用工具获取数据,然后基于数据进行分析
|
1. 你必须先调用工具获取数据,然后基于数据进行分析
|
||||||
@@ -40,53 +46,120 @@ SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中
|
|||||||
DAILY_ANALYSIS_PROMPT = """请对 {trade_date} 的A股市场进行收盘分析,生成一份完整的分析报告。
|
DAILY_ANALYSIS_PROMPT = """请对 {trade_date} 的A股市场进行收盘分析,生成一份完整的分析报告。
|
||||||
|
|
||||||
{prev_report_section}
|
{prev_report_section}
|
||||||
|
{prev_snapshot_section}
|
||||||
|
|
||||||
请先调用以下工具获取数据:
|
请先调用以下工具获取数据:
|
||||||
1. get_market_dashboard - 获取市场整体数据
|
1. get_market_dashboard - 获取市场整体数据(含涨跌统计、市场温度、连板梯队 limitLadder、板块资金流 sectorFundFlow、两融、海外与期货 globalMarkets)
|
||||||
2. get_theme_history(date="{trade_date}") - 获取今日题材涨幅
|
2. get_theme_history(date="{trade_date}") - 获取今日题材涨幅
|
||||||
3. get_active_core_stocks - 获取核心股数据
|
3. get_active_core_stocks - 获取核心股数据
|
||||||
|
4. get_news(limit=30) - 获取今日财经快讯
|
||||||
|
|
||||||
然后基于数据生成报告,结构如下:
|
生成报告时必须遵守以下格式规则:
|
||||||
|
|
||||||
## 一、市场总览
|
1. 报告标题(# 一级标题)之后的第一行,必须是一个引用块"定调摘要",格式严格为:
|
||||||
- 主要指数表现(上证、深证、创业板)
|
> 今日定调:<一句话核心结论,不超过80字,必须包含1-2个关键数字(如成交额、涨停家数、市场温度)>
|
||||||
- 涨跌家数统计
|
|
||||||
- 市场温度评估
|
|
||||||
|
|
||||||
## 二、题材热点分析
|
2. 量能与情绪类数字必须给环比:若上方提供了"前一交易日盘面数据快照",成交额、涨跌家数、涨停数、两融余额、市场温度等在与昨日对比后表述(如"成交额2.05万亿,较昨日缩量约700亿");没有昨日快照则如实说明"暂无昨日数据"。
|
||||||
- 今日涨幅前5题材
|
|
||||||
- 持续活跃的题材
|
|
||||||
- 新兴热点题材
|
|
||||||
|
|
||||||
## 三、核心股追踪
|
3. 连板梯队必须完整呈现 get_market_dashboard 返回的 limitLadder:从最高连板到2连板逐级列表格,每只标注涨停原因(reason字段)与封单金额(sealWan,单位万,为空则不写);首板只挑3-5只人气最高的点评。
|
||||||
- 连板股分析
|
|
||||||
- 核心股表现
|
|
||||||
- 龙头股辨识
|
|
||||||
|
|
||||||
## 四、关注方向
|
4. 板块资金面必须引用 get_market_dashboard 返回的 sectorFundFlow:行业主力净流入TOP3、净流出TOP3、概念净流入TOP3(单位亿元),结合题材分析说明资金动向。
|
||||||
- 明日值得关注的题材方向
|
|
||||||
- 潜在的交易机会
|
|
||||||
|
|
||||||
## 五、下个交易日建议
|
5. 海外市场与国内期货必须引用 get_market_dashboard 返回的 globalMarkets:overseas 为海外主要指数(纳斯达克/道琼斯/标普500/恒生/日经/富时),futures 为国内期货主力合约(按成交额降序,已含价格与涨跌幅);点评与A股关联度高的品种(股指期货、原油、贵金属、黑色系),数据缺失则如实说明。
|
||||||
- 明日大盘预判(支撑/压力位)
|
|
||||||
- 建议关注的题材方向(2-3个)
|
|
||||||
- 建议关注的核心股(附理由)
|
|
||||||
- 操作策略(仓位建议、买卖时机)
|
|
||||||
- 需要规避的方向
|
|
||||||
|
|
||||||
## 六、风险提示
|
6. 重要消息面必须基于 get_news 返回的快讯整理:挑5-8条对次日盘面影响最大的消息,每条格式为"【分类】一句话新闻 —— 一句影响解读"(分类用:宏观/政策/行业/公司/海外);快讯中若没有某方面的重要消息,如实说明,严禁编造工具中不存在的新闻。
|
||||||
- 需要警惕的风险因素
|
|
||||||
- 操作建议
|
7. 适当使用表格展示数据对比。
|
||||||
|
|
||||||
|
然后基于数据生成报告。报告共八章,将由系统分三次调用完成,每次调用只负责其中一部分,具体写作指令由后续消息给出。
|
||||||
|
|
||||||
请用 Markdown 格式输出,适当使用表格展示数据对比。"""
|
请用 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:
|
async def call_llm(messages: list, tools: list = None) -> dict:
|
||||||
"""调用 OpenAI 兼容 API"""
|
"""调用 OpenAI 兼容 API(流式)。
|
||||||
|
|
||||||
|
必须用 stream:网关对非流式请求有约120s的代理超时,长生成会被 502 掐断;
|
||||||
|
流式下字节持续到达不会被判定超时。返回结构与非流式一致。
|
||||||
|
"""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
payload = {
|
payload = {
|
||||||
"model": AI_MODEL,
|
"model": AI_MODEL,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
|
"stream": True,
|
||||||
}
|
}
|
||||||
if AI_MAX_TOKENS is not None:
|
if AI_MAX_TOKENS is not None:
|
||||||
payload["max_tokens"] = AI_MAX_TOKENS
|
payload["max_tokens"] = AI_MAX_TOKENS
|
||||||
@@ -96,17 +169,40 @@ async def call_llm(messages: list, tools: list = None) -> dict:
|
|||||||
payload["tools"] = tools
|
payload["tools"] = tools
|
||||||
payload["tool_choice"] = "auto"
|
payload["tool_choice"] = "auto"
|
||||||
|
|
||||||
resp = await client.post(
|
max_attempts = 4
|
||||||
f"{AI_API_BASE}/chat/completions",
|
for attempt in range(1, max_attempts + 1):
|
||||||
headers={
|
try:
|
||||||
"Authorization": f"Bearer {AI_API_KEY}",
|
async with client.stream(
|
||||||
"Content-Type": "application/json",
|
"POST",
|
||||||
},
|
f"{AI_API_BASE}/chat/completions",
|
||||||
json=payload,
|
headers={
|
||||||
timeout=120,
|
"Authorization": f"Bearer {AI_API_KEY}",
|
||||||
)
|
"Content-Type": "application/json",
|
||||||
resp.raise_for_status()
|
"Accept": "text/event-stream",
|
||||||
return resp.json()
|
},
|
||||||
|
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:
|
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}
|
{"id": int, "tokens_used": int, "tools_used": list}
|
||||||
"""
|
"""
|
||||||
prev_report_section = _get_prev_report_section(trade_date)
|
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 = [
|
messages = [
|
||||||
{"role": "system", "content": SYSTEM_PROMPT},
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
{"role": "user", "content": DAILY_ANALYSIS_PROMPT.format(
|
{"role": "user", "content": DAILY_ANALYSIS_PROMPT.format(
|
||||||
trade_date=trade_date,
|
trade_date=trade_date,
|
||||||
prev_report_section=prev_report_section
|
prev_report_section=prev_report_section,
|
||||||
|
prev_snapshot_section=prev_snapshot_section,
|
||||||
)}
|
)}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# ── 阶段一:工具轮(只获取数据,模型若直接开写报告则丢弃,由阶段二重写) ──
|
||||||
tools_used = []
|
tools_used = []
|
||||||
total_tokens = 0
|
total_tokens = 0
|
||||||
final_content = ""
|
|
||||||
was_truncated = False
|
was_truncated = False
|
||||||
max_rounds = 30 # 安全上限,正常分析约 3-8 轮
|
max_rounds = 8
|
||||||
|
|
||||||
for i in range(max_rounds):
|
for i in range(max_rounds):
|
||||||
response = await call_llm(messages, tools=TOOLS)
|
response = await call_llm(messages, tools=TOOLS)
|
||||||
@@ -139,52 +240,208 @@ async def collect_ai_analysis(trade_date: str) -> dict:
|
|||||||
|
|
||||||
choice = response["choices"][0]
|
choice = response["choices"][0]
|
||||||
message = choice["message"]
|
message = choice["message"]
|
||||||
messages.append(message)
|
|
||||||
|
|
||||||
finish_reason = choice.get("finish_reason", "")
|
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":
|
if finish_reason == "tool_calls" and message.get("tool_calls"):
|
||||||
final_content = message.get("content") or ""
|
messages.append(message)
|
||||||
break
|
for tool_call in message["tool_calls"]:
|
||||||
|
|
||||||
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", []):
|
|
||||||
func_name = tool_call["function"]["name"]
|
func_name = tool_call["function"]["name"]
|
||||||
func_args = json.loads(tool_call["function"]["arguments"])
|
func_args = json.loads(tool_call["function"]["arguments"])
|
||||||
tools_used.append(func_name)
|
tools_used.append(func_name)
|
||||||
|
|
||||||
result = await execute_tool(func_name, func_args)
|
result = await execute_tool(func_name, func_args)
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"tool_call_id": tool_call["id"],
|
"tool_call_id": tool_call["id"],
|
||||||
"content": result
|
"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)
|
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}
|
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:
|
||||||
|
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()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
cursor = conn.execute(
|
row = conn.execute(
|
||||||
"""INSERT OR REPLACE INTO ai_reports
|
"SELECT trade_date, payload FROM daily_market_stats WHERE trade_date < ? ORDER BY trade_date DESC LIMIT 1",
|
||||||
(trade_date, report_type, title, content, summary, tools_used, model, tokens_used)
|
(trade_date,),
|
||||||
VALUES (?, 'daily', ?, ?, ?, ?, ?, ?)""",
|
).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,
|
trade_date,
|
||||||
f"{trade_date} A股收盘分析",
|
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),
|
json.dumps(tools_used),
|
||||||
AI_MODEL,
|
AI_MODEL,
|
||||||
tokens_used,
|
tokens_used,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ TOOLS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "get_market_dashboard",
|
"name": "get_market_dashboard",
|
||||||
"description": "获取A股市场看板数据,包含主要指数行情、涨跌统计、行业强度、概念热度、事件情报、市场温度评分",
|
"description": "获取A股市场看板数据,包含主要指数行情、全市场涨跌统计(涨跌家数/涨停/跌停/炸板率/成交额)、市场温度评分与竞价信号、行业强度榜、概念热度、板块主力资金流、两融余额、海外主要指数与国内期货主力合约(globalMarkets字段)、完整连板梯队(limitLadder字段,含涨停原因与封单金额)、事件情报(热门股/龙虎榜/飙升/异动)",
|
||||||
"parameters": {"type": "object", "properties": {}, "required": []}
|
"parameters": {"type": "object", "properties": {}, "required": []}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -71,6 +71,20 @@ TOOLS = [
|
|||||||
"required": ["code", "name"]
|
"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("name", ""),
|
||||||
arguments.get("days", 30)
|
arguments.get("days", 30)
|
||||||
)
|
)
|
||||||
|
elif tool_name == "get_news":
|
||||||
|
return await _get_news(arguments.get("limit", 30))
|
||||||
else:
|
else:
|
||||||
return json.dumps({"error": f"未知工具: {tool_name}"})
|
return json.dumps({"error": f"未知工具: {tool_name}"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(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:
|
async def _get_market_dashboard() -> str:
|
||||||
"""获取市场看板数据"""
|
"""获取市场看板数据"""
|
||||||
from routes.market_dashboard import _build_dashboard
|
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"]:
|
if s["lastAppear"] is None or r["trade_date"] > s["lastAppear"]:
|
||||||
s["lastAppear"] = r["trade_date"]
|
s["lastAppear"] = r["trade_date"]
|
||||||
|
|
||||||
stocks = list(stock_days.values())
|
# 只保留最近10日中出现次数最多的前25只(全量可达145KB,会把上下文撑爆)
|
||||||
stocks.sort(key=lambda x: x.get("lastAppear") or "", reverse=True)
|
stocks = sorted(stock_days.values(), key=lambda x: -x["appearCount"])[:25]
|
||||||
stocks.sort(key=lambda x: -x["appearCount"])
|
keep_codes = {s["stockCode"] for s in stocks}
|
||||||
|
|
||||||
themes_rows = conn.execute(
|
themes_rows = conn.execute(
|
||||||
f"""SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes
|
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()
|
).fetchall()
|
||||||
themes_by_stock = {}
|
themes_by_stock = {}
|
||||||
for t in themes_rows:
|
for t in themes_rows:
|
||||||
|
if t["stock_code"] not in keep_codes:
|
||||||
|
continue
|
||||||
per = themes_by_stock.setdefault(t["stock_code"], {})
|
per = themes_by_stock.setdefault(t["stock_code"], {})
|
||||||
per.setdefault(t["theme_code"], {"theme_code": t["theme_code"], "theme_name": t["theme_name"]})
|
per.setdefault(t["theme_code"], {"theme_code": t["theme_code"], "theme_name": t["theme_name"]})
|
||||||
for s in stocks:
|
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
|
latest = dates[-1] if dates else None
|
||||||
for s in stocks:
|
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 displayData = chartPeriod === "d" ? dailyData : minuteData;
|
||||||
const currentLoading = chartPeriod === "d" ? chartLoading : minuteLoading;
|
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 (
|
return (
|
||||||
<Card className="shadow-lg">
|
<Card className="shadow-lg">
|
||||||
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="px-2 md:px-6 pb-2 md:pb-6">
|
<CardContent className="px-2 md:px-6 pb-2 md:pb-6">
|
||||||
{currentLoading ? (
|
{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="flex items-center text-muted-foreground text-sm">
|
||||||
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>
|
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>
|
||||||
K线数据加载中...
|
K线数据加载中...
|
||||||
@@ -221,18 +237,11 @@ export function KLineCard({ code, addedAt, ready }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<KLineChart
|
<KLineChart
|
||||||
data={displayData.map((d) => ({
|
data={chartData}
|
||||||
time: d.dateMs,
|
|
||||||
open: d.open,
|
|
||||||
close: d.close,
|
|
||||||
high: d.high,
|
|
||||||
low: d.low,
|
|
||||||
volume: d.volume,
|
|
||||||
isAddedDate: d.isAddedDate,
|
|
||||||
}))}
|
|
||||||
mode={chartMode}
|
mode={chartMode}
|
||||||
hasAddedDate={ready}
|
hasAddedDate={ready}
|
||||||
indicators={indicators}
|
indicators={indicators}
|
||||||
|
defaultVisibleDays={chartPeriod === "d" ? 90 : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
ColorType,
|
ColorType,
|
||||||
CrosshairMode,
|
CrosshairMode,
|
||||||
LineStyle,
|
LineStyle,
|
||||||
|
TickMarkType,
|
||||||
createSeriesMarkers,
|
createSeriesMarkers,
|
||||||
type IChartApi,
|
type IChartApi,
|
||||||
type ISeriesApi,
|
type ISeriesApi,
|
||||||
@@ -41,6 +42,8 @@ interface Props {
|
|||||||
mode: "line" | "candle";
|
mode: "line" | "candle";
|
||||||
hasAddedDate?: boolean;
|
hasAddedDate?: boolean;
|
||||||
indicators: IndicatorToggles;
|
indicators: IndicatorToggles;
|
||||||
|
/** 默认只展示最近 N 个自然日(日线用),其余数据靠拖动查看;不传则铺满全部数据 */
|
||||||
|
defaultVisibleDays?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。
|
// lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。
|
||||||
@@ -61,9 +64,41 @@ const MACD_DIF = "#3b82f6";
|
|||||||
const MACD_DEA = "#f59e0b";
|
const MACD_DEA = "#f59e0b";
|
||||||
const RSI_COLOR = "#a855f7";
|
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 containerRef = useRef<HTMLDivElement>(null);
|
||||||
const chartRef = useRef<IChartApi | null>(null);
|
const chartRef = useRef<IChartApi | null>(null);
|
||||||
|
// 记录上次填充的数据集:仅数据集变化(首次加载/切换周期)时调整视口,
|
||||||
|
// 指标开关/显示方式切换时保留用户拖动缩放后的位置
|
||||||
|
const lastDataRef = useRef<KLineItem[] | null>(null);
|
||||||
const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null);
|
const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null);
|
||||||
const volSeriesRef = useRef<ISeriesApi<"Histogram"> | null>(null);
|
const volSeriesRef = useRef<ISeriesApi<"Histogram"> | null>(null);
|
||||||
// 指标 series(重建用)
|
// 指标 series(重建用)
|
||||||
@@ -73,8 +108,15 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
|||||||
|
|
||||||
// 容器高度随指标 pane 数量增长,避免主图被压缩
|
// 容器高度随指标 pane 数量增长,避免主图被压缩
|
||||||
const extraPanes = [indicators.macd, indicators.rsi].filter(Boolean).length;
|
const extraPanes = [indicators.macd, indicators.rsi].filter(Boolean).length;
|
||||||
// 固定图表高度:主图+成交量 300,每个指标副图 +95
|
// 图表总高 = 各 pane 高度之和(主图 + 成交量 + 指标副图×N)
|
||||||
const chartHeight = 300 + extraPanes * 95;
|
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 成交量
|
// 创建图表(仅一次):pane0 主图 + pane1 成交量
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -94,7 +136,12 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
|||||||
horzLines: { color: GRID, style: LineStyle.Dashed, visible: true },
|
horzLines: { color: GRID, style: LineStyle.Dashed, visible: true },
|
||||||
},
|
},
|
||||||
rightPriceScale: { borderColor: GRID },
|
rightPriceScale: { borderColor: GRID },
|
||||||
timeScale: { borderColor: GRID, timeVisible: false },
|
localization: { timeFormatter: formatCrosshairTime },
|
||||||
|
timeScale: {
|
||||||
|
borderColor: GRID,
|
||||||
|
timeVisible: false,
|
||||||
|
tickMarkFormatter: formatTickMark,
|
||||||
|
},
|
||||||
crosshair: { mode: CrosshairMode.Normal },
|
crosshair: { mode: CrosshairMode.Normal },
|
||||||
});
|
});
|
||||||
chartRef.current = chart;
|
chartRef.current = chart;
|
||||||
@@ -109,6 +156,7 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
|||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
volSeriesRef.current = vol;
|
volSeriesRef.current = vol;
|
||||||
|
applyPaneHeights(chart);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
chart.remove();
|
chart.remove();
|
||||||
@@ -118,6 +166,8 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
|||||||
maSeriesRef.current = [];
|
maSeriesRef.current = [];
|
||||||
macdSeriesRef.current = [];
|
macdSeriesRef.current = [];
|
||||||
rsiSeriesRef.current = [];
|
rsiSeriesRef.current = [];
|
||||||
|
// 图表实例销毁后重置,重建时重新应用默认视口(StrictMode 重挂载同样生效)
|
||||||
|
lastDataRef.current = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -223,6 +273,8 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
|||||||
);
|
);
|
||||||
rsiSeriesRef.current.push(s);
|
rsiSeriesRef.current.push(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyPaneHeights(chart);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模式切换:重建价格 series + 指标
|
// 模式切换:重建价格 series + 指标
|
||||||
@@ -310,7 +362,23 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
|||||||
createSeriesMarkers(ps, []);
|
createSeriesMarkers(ps, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
chart.timeScale().fitContent();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 颜色图例:根据启用的指标生成
|
// 颜色图例:根据启用的指标生成
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ export interface AiReport {
|
|||||||
toolsUsed: string[];
|
toolsUsed: string[];
|
||||||
model: string;
|
model: string;
|
||||||
tokens_used: number;
|
tokens_used: number;
|
||||||
|
generation_count?: number;
|
||||||
|
issue_number?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAiLatestReport(): Promise<AiReport> {
|
export async function fetchAiLatestReport(): Promise<AiReport> {
|
||||||
|
|||||||
+124
-35
@@ -4,6 +4,7 @@ import { ArrowLeft, Clock, Loader2, AlertCircle, Wrench, Calendar } from "lucide
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
import type { Components } from "react-markdown";
|
||||||
import { Mermaid } from "../components/Mermaid";
|
import { Mermaid } from "../components/Mermaid";
|
||||||
import { fetchAiLatestReport } from "../lib/ai-analysis-api";
|
import { fetchAiLatestReport } from "../lib/ai-analysis-api";
|
||||||
import { Card, CardContent } from "../components/ui/card";
|
import { Card, CardContent } from "../components/ui/card";
|
||||||
@@ -12,6 +13,51 @@ export const Route = createFileRoute("/ai-analysis")({
|
|||||||
component: AiAnalysisPage,
|
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() {
|
function AiAnalysisPage() {
|
||||||
const { data: report, isLoading, isError } = useQuery({
|
const { data: report, isLoading, isError } = useQuery({
|
||||||
queryKey: ["ai-report-latest"],
|
queryKey: ["ai-report-latest"],
|
||||||
@@ -19,6 +65,30 @@ function AiAnalysisPage() {
|
|||||||
retry: false,
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<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">
|
<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">
|
<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" />
|
<Calendar className="h-5 w-5 text-primary" />
|
||||||
AI 收盘分析
|
AI 收盘分析
|
||||||
|
{report?.issue_number ? (
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">总第 {report.issue_number} 期</span>
|
||||||
|
) : null}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</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">
|
<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">
|
<span className="flex items-center gap-1.5">
|
||||||
<Clock className="h-3.5 w-3.5 shrink-0" />
|
<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>
|
||||||
<span>{report.tokens_used?.toLocaleString()} tokens</span>
|
<span>{report.tokens_used?.toLocaleString()} tokens</span>
|
||||||
|
{(report.generation_count ?? 1) > 1 && (
|
||||||
|
<span>第 {report.generation_count} 次生成</span>
|
||||||
|
)}
|
||||||
{report.toolsUsed && report.toolsUsed.length > 0 && (
|
{report.toolsUsed && report.toolsUsed.length > 0 && (
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Wrench className="h-3.5 w-3.5 shrink-0" />
|
<Wrench className="h-3.5 w-3.5 shrink-0" />
|
||||||
@@ -66,44 +142,57 @@ function AiAnalysisPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Report Content */}
|
{/* 今日定调摘要 */}
|
||||||
<Card className="bg-card border-border">
|
{report.summary && (
|
||||||
<CardContent className="p-3 sm:p-4 md:p-6 ai-report-content">
|
<div className="mb-3 sm:mb-4 rounded-lg border border-primary/30 bg-primary/5 px-3 sm:px-4 py-3">
|
||||||
<Markdown
|
<div className="flex items-start gap-2.5">
|
||||||
remarkPlugins={[remarkGfm]}
|
<span className="shrink-0 mt-0.5 rounded bg-primary/10 px-1.5 py-0.5 text-[11px] font-semibold text-primary">
|
||||||
components={{
|
今日定调
|
||||||
table({ children, ...props }) {
|
</span>
|
||||||
return (
|
<p className="text-sm leading-relaxed">{report.summary}</p>
|
||||||
<div className="overflow-x-auto -mx-3 sm:mx-0 px-3 sm:px-0">
|
</div>
|
||||||
<table {...props}>{children}</table>
|
</div>
|
||||||
</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 生成,仅供参考,不构成投资建议
|
const { intro, sections } = splitReport(report.content);
|
||||||
</p>
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default AiAnalysisPage;
|
||||||
|
|||||||
Reference in New Issue
Block a user