Compare commits
15
Commits
73d259b3a1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca0d908129 | ||
|
|
9d1e1c9af5 | ||
|
|
1df1f77b13 | ||
|
|
229362533b | ||
|
|
6c7d8fe4bc | ||
|
|
e24b1a360b | ||
|
|
fefb1b4736 | ||
|
|
734a449037 | ||
|
|
854427092d | ||
|
|
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,25 @@
|
||||
- 资金流向数据获取失败时,前端通过 `fundFlowError` 状态显示错误信息,便于排查问题
|
||||
- ❌ 东方财富 API(push2his.eastmoney.com)在 Edge Function 环境被拒绝访问(peer closed connection),主力净流入数据无法获取,显示为 "-"
|
||||
- ✅ 替代方案:使用腾讯实时行情API的外盘(索引7)和内盘(索引8)数据计算净主动买入额 = (外盘 - 内盘) × 当前价 × 100(单位:元)
|
||||
## 每日 AI 分析(backend FastAPI,15:10 自动触发)
|
||||
|
||||
- 报告结构约定:正文以 `# {trade_date} A股收盘分析报告` 开头后直接进入第一章,无"今日定调"摘要(已移除:prompt 不生成、前端无高亮框);`summary` 为去掉标题行与 Markdown 标记后的正文前200字,供管理列表展示
|
||||
- 八章固定顺序:一、市场总览|二、题材热点分析|三、核心股追踪|四、资金与筹码|五、重要消息面|六、海外市场与国内期货|七、明日展望|八、风险提示;分段归属:part1=一+二、part2=三+四、part3=五~八(原"四、关注方向/五、下个交易日建议"内容重复,已合并为"七、明日展望",板块资金流从第二章挪出独立成第四章)
|
||||
- prompt 工程约定(改模板必读):①取数指令放 prompt 最前(要求同一轮并行调用 4 个核心工具,代码用 `REQUIRED_TOOLS` 校验,缺则补一轮并在 user 消息里点名缺哪些);②环比快照紧随其后、前日报告全文放最后(避免长文本把取数指令挤出注意力区);③凡写数字的规则必须同时写"找不到就写 —(今日无数据)",否则模型会用常识补全;④涨跌幅/环比/净额一律带 + 或 -,这是前端 `colorizeText` 红涨绿跌的着色依据;⑤模板用 `.format()` 渲染,正文中出现裸 `{}` 会直接报错
|
||||
- 分段生成必须回灌前文(`messages.append({"role":"assistant","content":part_content})`):否则后段看不到前段,会重复铺同一段数据、数字口径打架;生成失败的占位段不回灌
|
||||
- 环比数据:`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,20 @@ CREATE TABLE IF NOT EXISTS ai_reports (
|
||||
tools_used TEXT,
|
||||
model TEXT NOT NULL,
|
||||
tokens_used INTEGER,
|
||||
generation_count INTEGER NOT NULL DEFAULT 1,
|
||||
llm_calls INTEGER,
|
||||
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 +118,17 @@ 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",
|
||||
"ALTER TABLE ai_reports ADD COLUMN llm_calls INTEGER",
|
||||
):
|
||||
try:
|
||||
conn.execute(alter_sql)
|
||||
except sqlite3.OperationalError:
|
||||
pass # 列已存在
|
||||
|
||||
# 清理过期缓存
|
||||
from services.cache import clean_expired
|
||||
clean_expired()
|
||||
|
||||
Vendored
+2
-2
@@ -334,7 +334,7 @@ function renderReports(list) {
|
||||
document.getElementById('recentBody').innerHTML = list.map(r =>
|
||||
`<tr>
|
||||
<td>${r.trade_date}</td>
|
||||
<td><a href="/ai-analysis" target="_blank" style="color:var(--primary);text-decoration:none">${r.title || '-'}</a></td>
|
||||
<td><a href="/ai-analysis?date=${r.trade_date}" target="_blank" style="color:var(--primary);text-decoration:none">${r.title || '-'}</a></td>
|
||||
<td>${r.tokens_used || 0}</td>
|
||||
</tr>`
|
||||
).join('') || '<tr><td colspan="3" style="color:var(--muted-foreground);text-align:center">暂无数据</td></tr>';
|
||||
@@ -349,7 +349,7 @@ function renderAllReports(list) {
|
||||
<td>${r.tokens_used || 0}</td>
|
||||
<td>${r.created_at || '-'}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<a href="/ai-analysis" target="_blank" class="btn btn-ghost btn-sm" style="padding:3px 10px;font-size:11px;text-decoration:none">查看</a>
|
||||
<a href="/ai-analysis?date=${r.trade_date}" target="_blank" class="btn btn-ghost btn-sm" style="padding:3px 10px;font-size:11px;text-decoration:none">查看</a>
|
||||
<button class="btn btn-destructive btn-sm" style="padding:3px 10px;font-size:11px" onclick="deleteReport(${r.id})">删除</button>
|
||||
</td>
|
||||
</tr>`
|
||||
|
||||
@@ -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, llm_calls, 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, llm_calls, created_at, updated_at "
|
||||
"FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50"
|
||||
).fetchall()
|
||||
items = []
|
||||
@@ -73,6 +77,29 @@ async def list_reports():
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/ai-analysis/by-date/{trade_date}", summary="按交易日获取 AI 分析报告")
|
||||
async def get_report_by_date(trade_date: str):
|
||||
try:
|
||||
datetime.strptime(trade_date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="日期格式错误,应为 YYYY-MM-DD")
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""SELECT r.*,
|
||||
(SELECT COUNT(*) FROM ai_reports WHERE trade_date <= r.trade_date) AS issue_number
|
||||
FROM ai_reports r WHERE r.trade_date = ? AND r.report_type = 'daily' LIMIT 1""",
|
||||
(trade_date,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"{trade_date} 暂无分析报告")
|
||||
item = dict_from_row(row)
|
||||
item["toolsUsed"] = json.loads(item.pop("tools_used") or "[]")
|
||||
return JSONResponse({"data": item}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/ai-analysis/{report_id}", summary="AI 分析报告详情")
|
||||
async def get_report(report_id: int):
|
||||
conn = get_connection()
|
||||
|
||||
@@ -20,11 +20,11 @@ def _recent_trade_dates(conn, n: int = 10) -> list[str]:
|
||||
return [r["trade_date"] for r in reversed(rows)]
|
||||
|
||||
|
||||
@router.get("/active", summary="活跃核心股 + 最近10日涨幅矩阵")
|
||||
async def active_core_stocks():
|
||||
@router.get("/active", summary="活跃核心股 + 最近N日涨幅矩阵")
|
||||
async def active_core_stocks(days: int = Query(10, ge=1, le=30, description="交易日天数")):
|
||||
conn = get_connection()
|
||||
try:
|
||||
dates = _recent_trade_dates(conn, 10)
|
||||
dates = _recent_trade_dates(conn, days)
|
||||
if not dates:
|
||||
return JSONResponse({"dates": [], "stocks": []}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
@@ -90,6 +90,56 @@ async def active_core_stocks():
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/consecutive", summary="最近N日每天上榜的核心股")
|
||||
async def consecutive_core_stocks(days: int = Query(3, ge=2, le=10, description="交易日天数")):
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT ?",
|
||||
(days,)
|
||||
).fetchall()
|
||||
recent_dates = [r["trade_date"] for r in reversed(rows)]
|
||||
|
||||
if len(recent_dates) < days:
|
||||
return JSONResponse({"days": days, "stocks": []}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
placeholders = ",".join("?" * len(recent_dates))
|
||||
rows = conn.execute(
|
||||
f"""SELECT trade_date, stock_code, stock_name, f3, cover_count
|
||||
FROM daily_core_stocks
|
||||
WHERE trade_date IN ({placeholders})""",
|
||||
recent_dates,
|
||||
).fetchall()
|
||||
|
||||
stock_dates: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
code = r["stock_code"]
|
||||
s = stock_dates.setdefault(code, {
|
||||
"stockCode": code,
|
||||
"stockName": r["stock_name"],
|
||||
"dates": set(),
|
||||
"dailyGains": {},
|
||||
})
|
||||
s["dates"].add(r["trade_date"])
|
||||
s["dailyGains"][r["trade_date"]] = r["f3"]
|
||||
|
||||
results = []
|
||||
for code, s in stock_dates.items():
|
||||
if len(s["dates"]) >= days:
|
||||
results.append({
|
||||
"stockCode": code,
|
||||
"stockName": s["stockName"],
|
||||
"consecutiveDays": len(s["dates"]),
|
||||
"recentDates": sorted(s["dates"]),
|
||||
"dailyGains": s["dailyGains"],
|
||||
})
|
||||
|
||||
results.sort(key=lambda x: -x["consecutiveDays"])
|
||||
return JSONResponse({"days": days, "recentDates": recent_dates, "stocks": results}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/history", summary="指定交易日核心股(含所属题材)")
|
||||
async def core_stock_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
|
||||
conn = get_connection()
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
+399
-82
@@ -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
|
||||
|
||||
@@ -16,7 +21,7 @@ from database import get_connection
|
||||
|
||||
_CST = timezone(timedelta(hours=8))
|
||||
|
||||
SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中发现投资机会。
|
||||
SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中发现投资机会。为收盘后的每日复盘报告供稿。读者是有一定经验、但没时间盯盘的短线与波段投资者。
|
||||
|
||||
你的分析风格:
|
||||
- 数据驱动,基于真实数据而非主观臆断
|
||||
@@ -25,68 +30,163 @@ SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中
|
||||
- 风险提示,每次推荐都需说明风险点
|
||||
|
||||
可用工具:
|
||||
- get_market_dashboard: 获取市场整体数据(指数/涨跌统计/行业强度/事件情报/市场温度)
|
||||
- get_market_dashboard: 获取市场整体数据(指数/涨跌统计/市场温度/连板梯队/行业强度/板块资金流/两融/海外指数与国内期货/事件情报)
|
||||
- get_theme_history: 获取指定日期的题材涨幅排行
|
||||
- get_active_core_stocks: 获取核心股追踪数据(10日涨幅矩阵+所属题材)
|
||||
- get_consecutive_core_stocks: 获取最近N个交易日每天都上榜的核心股(缺一日都不行),用于识别持续活跃的热点股
|
||||
- get_stock_quote: 获取个股实时行情
|
||||
- get_fund_flow: 获取个股资金流向
|
||||
- get_news: 获取财经快讯(新浪7x24,用于重要消息面)
|
||||
|
||||
重要规则:
|
||||
1. 你必须先调用工具获取数据,然后基于数据进行分析
|
||||
2. 不要凭空编造数据,所有数据必须来自工具返回
|
||||
3. 如果工具返回空数据,如实说明数据不可用
|
||||
4. 分析完成后给出明确的结论和建议"""
|
||||
4. 分析完成后给出明确的结论和建议
|
||||
|
||||
DAILY_ANALYSIS_PROMPT = """请对 {trade_date} 的A股市场进行收盘分析,生成一份完整的分析报告。
|
||||
工作原则:
|
||||
1. 事实优先:所有数字、股票名称与代码、涨停原因、封单金额必须能在工具返回中找到出处;工具没有的写「—(今日无数据)」,严禁凭记忆、常识或推理补全。
|
||||
2. 时间锚定:只分析给定交易日当天及之前的真实数据,禁止引用训练记忆中的行情、政策或题材。
|
||||
3. 判断必须挂数字:每个结论后面要有价格/涨跌幅/金额/家数支撑,不做无数据的定性判断。
|
||||
4. 说人话:结论先行、短句、少形容词;禁用「整体来看」「值得注意的是」「情绪有所回暖」这类无信息量的套话。
|
||||
|
||||
输出格式(前端按 ## 二级标题切卡片渲染,红涨绿跌依赖数字符号):
|
||||
- 章节标题严格用 `## 一、xxx` 形式,标题不超过 12 字
|
||||
- 涨跌幅、环比变化、资金净额等有方向的数字必须带 + 或 - 号(如 +2.31%、-15.6亿);不带符号的数字不会被着色
|
||||
- 多维度对比一律用 Markdown 表格,表头标注单位"""
|
||||
|
||||
# 一次工具轮就必须拿全的核心工具:模型常只调 1-2 个就开写,缺失会让对应章节数据空洞
|
||||
REQUIRED_TOOLS = ("get_market_dashboard", "get_theme_history",
|
||||
"get_active_core_stocks", "get_consecutive_core_stocks", "get_news")
|
||||
|
||||
DAILY_ANALYSIS_PROMPT = """请对 {trade_date}(A股交易日) 的A股市场进行收盘分析,生成一份完整的分析报告。
|
||||
|
||||
{prev_report_section}
|
||||
{prev_snapshot_section}
|
||||
|
||||
请先调用以下工具获取数据:
|
||||
1. get_market_dashboard - 获取市场整体数据
|
||||
2. get_theme_history(date="{trade_date}") - 获取今日题材涨幅
|
||||
3. get_active_core_stocks - 获取核心股数据
|
||||
你的分析风格:
|
||||
- 数据驱动,基于真实数据而非主观臆断
|
||||
- 逻辑清晰,先总后分,层层递进
|
||||
- 观点明确,给出具体的操作建议
|
||||
- 风险提示,每次推荐都需说明风险点
|
||||
|
||||
然后基于数据生成报告,结构如下:
|
||||
重要规则:
|
||||
1. 你必须先调用工具获取数据,然后基于数据进行分析
|
||||
2. 不要凭空编造数据,所有数据必须来自工具返回
|
||||
3. 如果工具返回空数据,如实说明数据不可用
|
||||
4. 分析完成后给出明确的结论和建议
|
||||
|
||||
## 一、市场总览
|
||||
- 主要指数表现(上证、深证、创业板)
|
||||
- 涨跌家数统计
|
||||
- 市场温度评估
|
||||
■ 第一步 · 取数(必须一次并行完成)
|
||||
在同一次回复中并行发起以下 5 个工具调用,不要拆成多轮、不要只调其中一部分:
|
||||
1. get_market_dashboard() —— 指数、涨跌统计、市场温度、连板梯队 limitLadder、板块资金流 sectorFundFlow、两融、海外指数与国内期货 globalMarkets
|
||||
2. get_theme_history(date="{trade_date}") —— 今日题材涨幅榜
|
||||
3. get_active_core_stocks() —— 核心股 10 日涨幅矩阵与所属题材
|
||||
4. get_consecutive_core_stocks(days=3) —— 最近3个交易日每天都上榜的核心股(缺一日都不行)
|
||||
5. get_news(limit=30) —— 今日财经快讯
|
||||
只有在需要核实某只具体个股时,才额外调用 get_stock_quote / get_fund_flow,合计不超过 2 次。
|
||||
|
||||
## 二、题材热点分析
|
||||
- 今日涨幅前5题材
|
||||
- 持续活跃的题材
|
||||
- 新兴热点题材
|
||||
■ 第二步 · 写作(以下规约对每一部分都生效)
|
||||
0. 报告标题(# 一级标题)之后的第一行,必须是一个引用块"定调摘要",格式严格为:
|
||||
> 今日定调:<一句话核心结论,不超过80字,必须包含1-2个关键数字(如成交额、涨停家数、市场温度)>
|
||||
1. 事实约束:所有数字、个股代码与名称、涨停原因、封单金额必须出自工具返回,找不到就写「—(今日无数据)」,禁止推测或用常识填补;禁止用「预计/有望/大概率」代替数据。
|
||||
2. 环比约束:量能与情绪类数字必须给环比,若上方提供了"前一交易日盘面数据快照"(成交额、涨跌家数、涨停/跌停/炸板、市场温度、两融余额、主要指数),首次提到时必须写成「今日值(较昨日 ±变化)」;快照缺失时写「暂无昨日基准」,不得编造环比。
|
||||
3. 单位口径(全文统一,表头标注单位):成交额→亿元(≥1万亿时写 x.xx 万亿);资金净额→亿元;封单金额→万元(limitLadder 的 sealWan 字段);指数→点。
|
||||
4. 符号规范:涨跌幅、环比变化、净流入/流出一律带 + 或 -,这是前端红涨绿跌的着色依据。
|
||||
5. 连板梯队:连板梯队必须完整呈现 get_market_dashboard 返回的 limitLadder:从最高连板到2连板逐级列表格,每只标注涨停原因(reason字段)与封单金额(sealWan,单位万,为空则不写);首板只挑3-5只人气最高的点评。
|
||||
6. 板块资金:板块资金面必须引用 get_market_dashboard 返回的 sectorFundFlow:行业主力净流入TOP5、净流出TOP5、概念净流入TOP5(亿元,带符号),结合题材分析说明资金动向。
|
||||
7. 海外市场与国内期货:必须引用 get_market_dashboard 返回的 globalMarkets:overseas 为海外主要指数(纳斯达克/道琼斯/标普500/恒生/日经/富时),futures 为国内期货主力合约(按成交额降序,已含价格与涨跌幅);点评与A股关联度高的品种(股指期货、原油、贵金属、黑色系),数据缺失则如实说明。
|
||||
8. 消息面:重要消息面必须基于 get_news 返回的快讯整理:挑5-8条对次日盘面影响最大的消息,每条格式为"【分类】一句话新闻 —— 一句影响解读"(分类用:宏观/政策/行业/公司/海外);快讯中若没有某方面的重要消息,如实说明「今日无重要消息」,严禁编造工具中不存在的新闻。
|
||||
9. 适当使用表格展示数据对比。
|
||||
基于数据生成报告。报告共八章,固定顺序:一、市场总览|二、题材热点分析|三、核心股追踪|四、资金与筹码|五、重要消息面|六、海外市场与国内期货|七、下个交易日建议|八、风险提示。
|
||||
同一段数据只允许在它归属的那一章出现:指数与量能在第一章、题材涨幅在第二章、连板与核心股在第三章、资金流与两融在第四章,其他章节只做结论引用,不重复铺数据。
|
||||
系统会把八章拆成三次生成,每次只写被指定的部分。
|
||||
"""
|
||||
|
||||
## 三、核心股追踪
|
||||
- 连板股分析
|
||||
- 核心股表现
|
||||
- 龙头股辨识
|
||||
# 分段生成指令:网关对单次 LLM 请求有约120s硬超时,整篇报告一次生成必被掐断,
|
||||
# 故拆为三段(每段约1200-1600字),各自独立调用后拼接
|
||||
REPORT_PARTS = [
|
||||
"""现在写报告的【第1部分】,只输出这一部分,直接输出 Markdown,不要任何开场白或说明:
|
||||
1. 以 `# {title} A股收盘分析报告` 一级标题开头
|
||||
2. `## 一、市场总览`(指数表格、涨跌统计须环比、市场温度)
|
||||
3. `## 二、题材热点分析`(涨幅前5、持续活跃、新兴热点、退潮警示、板块主力资金流TOP3)
|
||||
全文控制在1600字以内。""",
|
||||
"""现在写报告的【第2部分】,只输出这一部分,直接输出 Markdown,不要重复之前内容:
|
||||
- `## 三、核心股追踪`(连板梯队完整表格:层级/股票/涨停原因/封单,首板挑3-5只人气股点评;连续上榜股票表格:股票/连续天数/近3日涨幅,点评持续活跃的核心标的;核心股表现;龙头辨识)
|
||||
- `## 四、资金与筹码`:sectorFundFlow 的行业净流入 TOP3 / 净流出 TOP3 / 概念净流入 TOP3(亿元,带符号)、两融余额与变化、事件情报中值得注意的筹码信号
|
||||
全文控制在 1500 字以内。""",
|
||||
"""前两部分已在上下文中,现在续写【第3部分】,直接输出 Markdown,不要复述前文、不要写过渡句:
|
||||
- `## 五、重要消息面`:5-8条,格式【分类】新闻——影响解读
|
||||
- `## 六、海外市场与国内期货`(点评对次日A股的影响)
|
||||
全文控制在1300字以内。""",
|
||||
"""现在写报告的【第3部分】,只输出这一部分,直接输出 Markdown,不要重复之前内容:
|
||||
- `## 七、下个交易日建议`(大盘预判、题材方向、核心股、仓位策略、潜在交易机会、规避方向,结合市场情绪、资金流向、热点题材、核心股表现、消息面、海外市场与国内期货、风险提示,给出明确的结论和建议。)
|
||||
- `## 八、风险提示`
|
||||
全文控制在1900字以内。""",
|
||||
]
|
||||
## 五、下个交易日建议`(大盘预判、题材方向、核心股、仓位策略、规避方向)
|
||||
|
||||
## 四、关注方向
|
||||
- 明日值得关注的题材方向
|
||||
- 潜在的交易机会
|
||||
|
||||
## 五、下个交易日建议
|
||||
- 明日大盘预判(支撑/压力位)
|
||||
- 建议关注的题材方向(2-3个)
|
||||
- 建议关注的核心股(附理由)
|
||||
- 操作策略(仓位建议、买卖时机)
|
||||
- 需要规避的方向
|
||||
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"]
|
||||
|
||||
请用 Markdown 格式输出,适当使用表格展示数据对比。"""
|
||||
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 +196,40 @@ async def call_llm(messages: list, tools: list = None) -> dict:
|
||||
payload["tools"] = tools
|
||||
payload["tool_choice"] = "auto"
|
||||
|
||||
resp = await client.post(
|
||||
f"{AI_API_BASE}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {AI_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
max_attempts = 4
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{AI_API_BASE}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {AI_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
},
|
||||
json=payload,
|
||||
timeout=600,
|
||||
) as resp:
|
||||
if resp.status_code == 429 and attempt < max_attempts:
|
||||
wait = min(30 * attempt, 90)
|
||||
print(f"[ai-service] LLM 429 限流,{wait}s 后重试(第 {attempt}/{max_attempts - 1} 次)")
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
if resp.status_code >= 500 and attempt < max_attempts:
|
||||
print(f"[ai-service] LLM {resp.status_code},{min(15 * attempt, 60)}s 后重试")
|
||||
await asyncio.sleep(min(15 * attempt, 60))
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return await _consume_sse(resp)
|
||||
except httpx.TransportError as e:
|
||||
# 网络层错误(超时/断连)也值得重试
|
||||
if attempt < max_attempts:
|
||||
wait = min(15 * attempt, 60)
|
||||
print(f"[ai-service] LLM 网络错误({type(e).__name__}),{wait}s 后重试")
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
raise RuntimeError("LLM 调用重试次数耗尽")
|
||||
|
||||
|
||||
async def collect_ai_analysis(trade_date: str) -> dict:
|
||||
@@ -119,72 +242,257 @@ 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 = ""
|
||||
llm_calls = 0
|
||||
was_truncated = False
|
||||
max_rounds = 30 # 安全上限,正常分析约 3-8 轮
|
||||
max_rounds = 8
|
||||
tool_rounds = 0
|
||||
|
||||
for i in range(max_rounds):
|
||||
response = await call_llm(messages, tools=TOOLS)
|
||||
llm_calls += 1
|
||||
total_tokens += response.get("usage", {}).get("total_tokens", 0)
|
||||
|
||||
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"):
|
||||
tool_rounds += 1
|
||||
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
|
||||
})
|
||||
# 核心工具齐了就立即进分段写作(再问一轮只会空转120s);
|
||||
# 不齐则最多再补一轮,否则对应章节会无数据可写
|
||||
if all(t in tools_used for t in REQUIRED_TOOLS):
|
||||
break
|
||||
if tool_rounds >= 2:
|
||||
print(f"[ai-service] 核心工具仍不齐(已取:{sorted(set(tools_used))}),直接进入写作")
|
||||
break
|
||||
print(f"[ai-service] 核心工具不全,补一轮取数({tool_rounds}/2)")
|
||||
missing = [t for t in REQUIRED_TOOLS if t not in tools_used]
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": f"还缺以下数据,请立即调用后停止取数:{', '.join(missing)}"
|
||||
f"(get_theme_history 的 date 参数为 {trade_date})"
|
||||
})
|
||||
continue
|
||||
|
||||
summary = final_content[:200].replace("\n", " ") if final_content else ""
|
||||
report_id = _save_report(trade_date, final_content, summary, tools_used, total_tokens)
|
||||
# 非工具轮(模型直接开写/空返回):只要有工具结果就直接进入分段写作;
|
||||
# 一轮工具都没拿到则重试
|
||||
if tools_used:
|
||||
break
|
||||
print(f"[ai-service] 未获取到工具数据,重试({i + 1}/{max_rounds})")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
return {"id": report_id, "tokens_used": total_tokens, "tools_used": tools_used, "truncated": was_truncated}
|
||||
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)
|
||||
llm_calls += 1
|
||||
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"
|
||||
else:
|
||||
# 回灌前文:后段看不到前段内容会导致重复铺数据、数字口径打架
|
||||
messages.append({"role": "assistant", "content": part_content})
|
||||
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, llm_calls)
|
||||
print(f"[ai-service] 生成完成:LLM调用 {llm_calls} 次,tokens {total_tokens}")
|
||||
|
||||
return {"id": report_id, "tokens_used": total_tokens, "llm_calls": llm_calls, "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:
|
||||
"""摘要:去掉标题行与 Markdown 标记后截断 200 字(供管理列表展示)
|
||||
|
||||
正文首行是 `# XXXX A股收盘分析报告`,直接截断会让它占掉摘要大半。
|
||||
"""
|
||||
text = re.sub(r"^#.*$", "", content or "", flags=re.MULTILINE)
|
||||
text = re.sub(r"[>#*`|]", "", text)
|
||||
return re.sub(r"\s+", " ", text).strip()[:200]
|
||||
|
||||
|
||||
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()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"""INSERT OR REPLACE INTO ai_reports
|
||||
(trade_date, report_type, title, content, summary, tools_used, model, tokens_used)
|
||||
VALUES (?, 'daily', ?, ?, ?, ?, ?, ?)""",
|
||||
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, llm_calls: int = 0) -> 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, llm_calls, 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,
|
||||
llm_calls = excluded.llm_calls,
|
||||
updated_at = excluded.updated_at,
|
||||
generation_count = ai_reports.generation_count + 1""",
|
||||
(
|
||||
trade_date,
|
||||
f"{trade_date} A股收盘分析",
|
||||
@@ -193,10 +501,15 @@ def _save_report(trade_date: str, content: str, summary: str, tools_used: list,
|
||||
json.dumps(tools_used),
|
||||
AI_MODEL,
|
||||
tokens_used,
|
||||
)
|
||||
llm_calls,
|
||||
),
|
||||
)
|
||||
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()
|
||||
|
||||
@@ -213,7 +526,11 @@ def _get_prev_report_section(trade_date: str) -> str:
|
||||
return ""
|
||||
prev_date = row["trade_date"]
|
||||
prev_content = row["content"] or ""
|
||||
return f"""以下是前一个交易日({prev_date})的分析报告,请参考其中的分析逻辑和关注方向,结合今日数据进行对比分析:
|
||||
return f"""以下是前一个交易日({prev_date})的分析报告,仅用于保持分析连续性:
|
||||
|
||||
- 今日数据优先:两者冲突时以今日工具数据为准,并在正文点出变化(如"昨日提示的××今日××")
|
||||
- 禁止照搬昨日结论、禁止复制昨日段落
|
||||
- 重点核对昨日"明日展望"与"风险提示"里提到的方向是否兑现;未兑现的要在正文中说明
|
||||
|
||||
{prev_content}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ TOOLS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_market_dashboard",
|
||||
"description": "获取A股市场看板数据,包含主要指数行情、涨跌统计、行业强度、概念热度、事件情报、市场温度评分",
|
||||
"description": "获取A股市场看板数据,包含主要指数行情、全市场涨跌统计(涨跌家数/涨停/跌停/炸板率/成交额)、市场温度评分与竞价信号、行业强度榜、概念热度、板块主力资金流、两融余额、海外主要指数与国内期货主力合约(globalMarkets字段)、完整连板梯队(limitLadder字段,含涨停原因与封单金额)、事件情报(热门股/龙虎榜/飙升/异动)",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []}
|
||||
}
|
||||
},
|
||||
@@ -42,6 +42,20 @@ TOOLS = [
|
||||
"parameters": {"type": "object", "properties": {}, "required": []}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_consecutive_core_stocks",
|
||||
"description": "获取最近N个交易日每天都上榜的核心股(缺一日都不行),用于识别持续活跃的热点股",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"days": {"type": "integer", "description": "交易日天数,默认3"}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -71,6 +85,20 @@ TOOLS = [
|
||||
"required": ["code", "name"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_news",
|
||||
"description": "获取最近的财经快讯(新浪7x24,含宏观/行业/公司/海外动态),用于重要消息面梳理",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "description": "获取条数,默认30,最大50"}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -84,6 +112,8 @@ async def execute_tool(tool_name: str, arguments: dict) -> str:
|
||||
return await _get_theme_history(arguments.get("date", ""))
|
||||
elif tool_name == "get_active_core_stocks":
|
||||
return await _get_active_core_stocks()
|
||||
elif tool_name == "get_consecutive_core_stocks":
|
||||
return await _get_consecutive_core_stocks(arguments.get("days", 3))
|
||||
elif tool_name == "get_stock_quote":
|
||||
return await _get_stock_quote(arguments.get("code", ""))
|
||||
elif tool_name == "get_fund_flow":
|
||||
@@ -92,12 +122,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 +200,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 +211,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:
|
||||
@@ -192,6 +235,66 @@ async def _get_active_core_stocks() -> str:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def _get_consecutive_core_stocks(days: int = 3) -> str:
|
||||
"""获取最近N个交易日**每个交易日都上榜**的核心股,缺一日都不行"""
|
||||
from database import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
# 最近N个有数据的交易日
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT ?",
|
||||
(days,)
|
||||
).fetchall()
|
||||
recent_dates = [r["trade_date"] for r in reversed(rows)]
|
||||
|
||||
if len(recent_dates) < days:
|
||||
return json.dumps({"days": days, "stocks": []}, ensure_ascii=False)
|
||||
|
||||
# 查询这些日期的上榜记录
|
||||
placeholders = ",".join("?" * len(recent_dates))
|
||||
rows = conn.execute(
|
||||
f"""SELECT trade_date, stock_code, stock_name, f3, cover_count
|
||||
FROM daily_core_stocks
|
||||
WHERE trade_date IN ({placeholders})""",
|
||||
recent_dates,
|
||||
).fetchall()
|
||||
|
||||
# 按股票分组,记录上榜日期
|
||||
stock_dates: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
code = r["stock_code"]
|
||||
s = stock_dates.setdefault(code, {
|
||||
"stockCode": code,
|
||||
"stockName": r["stock_name"],
|
||||
"dates": set(),
|
||||
"dailyGains": {},
|
||||
})
|
||||
s["dates"].add(r["trade_date"])
|
||||
s["dailyGains"][r["trade_date"]] = r["f3"]
|
||||
|
||||
# 只保留N个交易日全部上榜的股票
|
||||
results = []
|
||||
for code, s in stock_dates.items():
|
||||
if len(s["dates"]) >= days:
|
||||
results.append({
|
||||
"stockCode": code,
|
||||
"stockName": s["stockName"],
|
||||
"consecutiveDays": len(s["dates"]),
|
||||
"recentDates": sorted(s["dates"]),
|
||||
"dailyGains": s["dailyGains"],
|
||||
})
|
||||
|
||||
results.sort(key=lambda x: -x["consecutiveDays"])
|
||||
|
||||
return json.dumps({
|
||||
"days": days,
|
||||
"recentDates": recent_dates,
|
||||
"stocks": results,
|
||||
}, ensure_ascii=False)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def _get_stock_quote(code: str) -> str:
|
||||
"""获取个股实时行情"""
|
||||
from services.tencent import fetch_quote
|
||||
|
||||
@@ -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,7 +362,23 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
||||
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,11 @@ export interface AiReport {
|
||||
toolsUsed: string[];
|
||||
model: string;
|
||||
tokens_used: number;
|
||||
generation_count?: number;
|
||||
llm_calls?: number | null;
|
||||
issue_number?: number;
|
||||
created_at: string;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export async function fetchAiLatestReport(): Promise<AiReport> {
|
||||
@@ -31,6 +35,13 @@ export async function fetchAiReports(): Promise<AiReport[]> {
|
||||
return result.data || [];
|
||||
}
|
||||
|
||||
export async function fetchAiReportByDate(tradeDate: string): Promise<AiReport> {
|
||||
const resp = await fetch(`${API_BASE}/api/ai-analysis/by-date/${tradeDate}`);
|
||||
if (!resp.ok) throw new Error(`请求失败 (${resp.status})`);
|
||||
const result = await resp.json();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function fetchAiReport(id: number): Promise<AiReport> {
|
||||
const resp = await fetch(`${API_BASE}/api/ai-analysis/${id}`);
|
||||
if (!resp.ok) throw new Error(`请求失败 (${resp.status})`);
|
||||
|
||||
@@ -33,11 +33,11 @@ export interface CoreStockHistoryResponse {
|
||||
items: CoreStockHistoryItem[];
|
||||
}
|
||||
|
||||
/** 获取活跃核心股 + 最近10日涨幅矩阵 */
|
||||
export async function fetchActiveCoreStocks(): Promise<ActiveCoreStocksResponse> {
|
||||
/** 获取活跃核心股 + 最近N日涨幅矩阵 */
|
||||
export async function fetchActiveCoreStocks(days: number = 10): Promise<ActiveCoreStocksResponse> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
try {
|
||||
const resp = await fetch(`${baseUrl}/api/core-stocks/active`, { method: "GET", cache: "no-store" });
|
||||
const resp = await fetch(`${baseUrl}/api/core-stocks/active?days=${days}`, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return { dates: [], stocks: [] };
|
||||
return resp.json();
|
||||
} catch (err) {
|
||||
|
||||
+184
-50
@@ -1,36 +1,153 @@
|
||||
import * as React from "react";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft, Clock, Loader2, AlertCircle, Wrench, Calendar } from "lucide-react";
|
||||
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 { fetchAiLatestReport, fetchAiReportByDate, fetchAiReports } from "../lib/ai-analysis-api";
|
||||
import { Card, CardContent } from "../components/ui/card";
|
||||
|
||||
export const Route = createFileRoute("/ai-analysis")({
|
||||
validateSearch: (search: Record<string, unknown>) => {
|
||||
return {
|
||||
date: typeof search.date === "string" ? search.date : undefined,
|
||||
// 兼容 ?data=YYYY-MM-DD 写法
|
||||
data: typeof search.data === "string" ? search.data : undefined,
|
||||
};
|
||||
},
|
||||
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 };
|
||||
});
|
||||
const intro = parts[0].trim();
|
||||
// intro 只剩标题(无其他内容)时置空,避免渲染一张只有大标题的空卡片
|
||||
const introBody = intro.replace(/^#[^\n]*$/m, "").trim();
|
||||
return { intro: introBody ? intro : "", sections };
|
||||
}
|
||||
|
||||
function AiAnalysisPage() {
|
||||
const navigate = useNavigate();
|
||||
const { date, data } = Route.useSearch();
|
||||
const tradeDate = date ?? data ?? undefined;
|
||||
const isHistorical = !!tradeDate;
|
||||
|
||||
const { data: report, isLoading, isError } = useQuery({
|
||||
queryKey: ["ai-report-latest"],
|
||||
queryFn: fetchAiLatestReport,
|
||||
queryKey: isHistorical ? ["ai-report", tradeDate] : ["ai-report-latest"],
|
||||
queryFn: () => (isHistorical ? fetchAiReportByDate(tradeDate!) : fetchAiLatestReport()),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: history = [] } = useQuery({
|
||||
queryKey: ["ai-reports"],
|
||||
queryFn: fetchAiReports,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const goDate = (d?: string) => {
|
||||
navigate({ to: "/ai-analysis", search: d ? { date: d } : {} });
|
||||
};
|
||||
|
||||
const selectedDate = tradeDate ?? report?.trade_date ?? "";
|
||||
|
||||
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">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-4 sm:mb-6">
|
||||
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<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 收盘分析
|
||||
</h1>
|
||||
<div className="flex items-center justify-between gap-2 sm:gap-3 mb-4 sm:mb-6">
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
||||
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors shrink-0">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-lg sm:text-xl md:text-2xl font-bold flex items-center gap-2 truncate">
|
||||
<Calendar className="h-5 w-5 text-primary shrink-0" />
|
||||
AI 收盘分析
|
||||
{report?.issue_number ? (
|
||||
<span className="text-xs font-normal text-muted-foreground whitespace-nowrap">总第 {report.issue_number} 期</span>
|
||||
) : null}
|
||||
</h1>
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<select
|
||||
value={history.some((h) => h.trade_date === selectedDate) ? selectedDate : ""}
|
||||
onChange={(e) => goDate(e.target.value || undefined)}
|
||||
className="text-xs sm:text-sm border border-border rounded-md bg-background px-2 py-1.5 text-foreground shrink-0"
|
||||
aria-label="选择历史报告日期"
|
||||
>
|
||||
<option value="">最新</option>
|
||||
{history.map((h) => (
|
||||
<option key={h.id} value={h.trade_date}>
|
||||
{h.trade_date}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -42,8 +159,20 @@ function AiAnalysisPage() {
|
||||
) : isError || !report ? (
|
||||
<div className="flex flex-col items-center gap-3 py-20">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
<p className="text-sm text-muted-foreground">暂无分析报告</p>
|
||||
<p className="text-xs text-muted-foreground/60">请通过管理面板触发 AI 分析</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isHistorical ? `${tradeDate} 暂无分析报告` : "暂无分析报告"}
|
||||
</p>
|
||||
{isHistorical ? (
|
||||
<Link
|
||||
to="/ai-analysis"
|
||||
search={{}}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
查看最新报告
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground/60">请通过管理面板触发 AI 分析</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -53,9 +182,13 @@ 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.llm_calls ? <span>{report.llm_calls} 次 LLM 调用</span> : null}
|
||||
{(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 +199,45 @@ function AiAnalysisPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Report Content */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-3 sm:p-4 md:p-6 ai-report-content">
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
table({ children, ...props }) {
|
||||
return (
|
||||
<div className="overflow-x-auto -mx-3 sm:mx-0 px-3 sm:px-0">
|
||||
<table {...props}>{children}</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
if (match && match[1] === "mermaid") {
|
||||
return <Mermaid chart={String(children).replace(/\n$/, "")} />;
|
||||
}
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{report.content}
|
||||
</Markdown>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<p className="text-xs text-muted-foreground/60 text-center mt-4 sm:mt-6">
|
||||
本报告由 AI 生成,仅供参考,不构成投资建议
|
||||
</p>
|
||||
{/* 报告正文:按 ## 章节分卡片渲染 */}
|
||||
{(() => {
|
||||
const { intro, sections } = splitReport(report.content);
|
||||
return (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{intro && (
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-3 sm:p-4 md:p-6 ai-report-content">
|
||||
<Markdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||||
{intro}
|
||||
</Markdown>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{sections.map((sec) => (
|
||||
<Card key={sec.title} className="bg-card border-border overflow-hidden">
|
||||
<div className="px-3 sm:px-4 md:px-6 pt-3 sm:pt-4 pb-2 sm:pb-3 border-b border-border/60">
|
||||
<h2 className="text-base sm:text-lg font-semibold">{sec.title}</h2>
|
||||
</div>
|
||||
<CardContent className="p-3 sm:p-4 md:p-6 ai-report-content">
|
||||
<Markdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||||
{sec.body}
|
||||
</Markdown>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Disclaimer */}
|
||||
<p className="text-xs text-muted-foreground/60 text-center mt-4 sm:mt-6">
|
||||
本报告由 AI 生成,仅供参考,不构成投资建议
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AiAnalysisPage;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Fragment } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { fetchActiveCoreStocks } from "@/lib/core-stock-api";
|
||||
import { ArrowLeft, RefreshCw, Flame } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
export const Route = createFileRoute("/core-stocks")({
|
||||
component: CoreStocksPage,
|
||||
@@ -16,9 +17,10 @@ function formatGain(v: number | null | undefined): string {
|
||||
}
|
||||
|
||||
function CoreStocksPage() {
|
||||
const [days, setDays] = useState(10);
|
||||
const { data, isLoading, isFetching, refetch } = useQuery({
|
||||
queryKey: ["core-stocks", "active"],
|
||||
queryFn: fetchActiveCoreStocks,
|
||||
queryKey: ["core-stocks", "active", days],
|
||||
queryFn: () => fetchActiveCoreStocks(days),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
@@ -48,9 +50,18 @@ function CoreStocksPage() {
|
||||
</header>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
活跃热点股(最近 10 个交易日内上榜)· 按上榜次数排序 · 共 {stocks.length} 只
|
||||
</p>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
活跃热点股(最近 {days} 个交易日内上榜)· 按上榜次数排序 · 共 {stocks.length} 只
|
||||
</p>
|
||||
<Tabs value={String(days)} onValueChange={(v) => setDays(Number(v))}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="3">近3日</TabsTrigger>
|
||||
<TabsTrigger value="5">近5日</TabsTrigger>
|
||||
<TabsTrigger value="10">近10日</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse rounded-xl bg-muted h-32" />
|
||||
|
||||
Reference in New Issue
Block a user