Compare commits
9
Commits
d8d36f32cf
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca0d908129 | ||
|
|
9d1e1c9af5 | ||
|
|
1df1f77b13 | ||
|
|
229362533b | ||
|
|
6c7d8fe4bc | ||
|
|
e24b1a360b | ||
|
|
fefb1b4736 | ||
|
|
734a449037 | ||
|
|
854427092d |
@@ -73,7 +73,10 @@
|
|||||||
- ✅ 替代方案:使用腾讯实时行情API的外盘(索引7)和内盘(索引8)数据计算净主动买入额 = (外盘 - 内盘) × 当前价 × 100(单位:元)
|
- ✅ 替代方案:使用腾讯实时行情API的外盘(索引7)和内盘(索引8)数据计算净主动买入额 = (外盘 - 内盘) × 当前价 × 100(单位:元)
|
||||||
## 每日 AI 分析(backend FastAPI,15:10 自动触发)
|
## 每日 AI 分析(backend FastAPI,15:10 自动触发)
|
||||||
|
|
||||||
- 报告结构约定:正文标题(#)后第一行必须是引用块 `> 今日定调:<80字内核心结论+关键数字>`;保存时由 `_extract_summary()` 正则提取进 `summary` 字段,前端顶部高亮展示,缺失时退回正文截断
|
- 报告结构约定:正文以 `# {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 须如实标注"暂无昨日基准"
|
- 环比数据:`collect_ai_analysis` 每次运行先采集当日盘面快照(指数+涨跌统计 JSON)写入 `daily_market_stats` 表(UNIQUE trade_date,upsert),再读上一交易日快照格式化为 prompt 中的"环比数据段";首跑无昨日数据时 AI 须如实标注"暂无昨日基准"
|
||||||
- 连板梯队:`get_market_dashboard` 返回 `limitLadder` 字段(完整版,2连板以上全量+首板前8),涨停原因/封单金额来自涨停池按 thscode 匹配;看板 events 里的天梯仍只取每层前2(保持 UI 精简),两处用途不同不要合并
|
- 连板梯队:`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)
|
- tokens 口径:`ai_reports.tokens_used` 只记"当次生成"消耗(约7万/份);同日重新生成走 UPSERT,`generation_count` 自增、`updated_at` 刷新、`created_at` 保留首次生成时间;表有 UNIQUE(trade_date, report_type),禁止改回 INSERT OR REPLACE 之外还要注意别用 lastrowid(UPSERT 更新时不可靠,须回查 id)
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ CREATE TABLE IF NOT EXISTS ai_reports (
|
|||||||
model TEXT NOT NULL,
|
model TEXT NOT NULL,
|
||||||
tokens_used INTEGER,
|
tokens_used INTEGER,
|
||||||
generation_count INTEGER NOT NULL DEFAULT 1,
|
generation_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
llm_calls INTEGER,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||||
updated_at TEXT,
|
updated_at TEXT,
|
||||||
UNIQUE(trade_date, report_type)
|
UNIQUE(trade_date, report_type)
|
||||||
@@ -121,6 +122,7 @@ def init_db():
|
|||||||
for alter_sql in (
|
for alter_sql in (
|
||||||
"ALTER TABLE ai_reports ADD COLUMN generation_count INTEGER NOT NULL DEFAULT 1",
|
"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 updated_at TEXT",
|
||||||
|
"ALTER TABLE ai_reports ADD COLUMN llm_calls INTEGER",
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
conn.execute(alter_sql)
|
conn.execute(alter_sql)
|
||||||
|
|||||||
Vendored
+2
-2
@@ -334,7 +334,7 @@ function renderReports(list) {
|
|||||||
document.getElementById('recentBody').innerHTML = list.map(r =>
|
document.getElementById('recentBody').innerHTML = list.map(r =>
|
||||||
`<tr>
|
`<tr>
|
||||||
<td>${r.trade_date}</td>
|
<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>
|
<td>${r.tokens_used || 0}</td>
|
||||||
</tr>`
|
</tr>`
|
||||||
).join('') || '<tr><td colspan="3" style="color:var(--muted-foreground);text-align:center">暂无数据</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.tokens_used || 0}</td>
|
||||||
<td>${r.created_at || '-'}</td>
|
<td>${r.created_at || '-'}</td>
|
||||||
<td style="white-space:nowrap">
|
<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>
|
<button class="btn btn-destructive btn-sm" style="padding:3px 10px;font-size:11px" onclick="deleteReport(${r.id})">删除</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`
|
</tr>`
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ async def admin_list_reports(request: Request):
|
|||||||
try:
|
try:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, "
|
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, "
|
||||||
"generation_count, created_at, updated_at "
|
"generation_count, llm_calls, 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 = []
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ async def list_reports():
|
|||||||
try:
|
try:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, "
|
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, "
|
||||||
"generation_count, created_at, updated_at "
|
"generation_count, llm_calls, 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 = []
|
||||||
@@ -77,6 +77,29 @@ async def list_reports():
|
|||||||
conn.close()
|
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 分析报告详情")
|
@router.get("/ai-analysis/{report_id}", summary="AI 分析报告详情")
|
||||||
async def get_report(report_id: int):
|
async def get_report(report_id: int):
|
||||||
conn = get_connection()
|
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)]
|
return [r["trade_date"] for r in reversed(rows)]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/active", summary="活跃核心股 + 最近10日涨幅矩阵")
|
@router.get("/active", summary="活跃核心股 + 最近N日涨幅矩阵")
|
||||||
async def active_core_stocks():
|
async def active_core_stocks(days: int = Query(10, ge=1, le=30, description="交易日天数")):
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
dates = _recent_trade_dates(conn, 10)
|
dates = _recent_trade_dates(conn, days)
|
||||||
if not dates:
|
if not dates:
|
||||||
return JSONResponse({"dates": [], "stocks": []}, headers=_NO_CACHE_HEADERS)
|
return JSONResponse({"dates": [], "stocks": []}, headers=_NO_CACHE_HEADERS)
|
||||||
|
|
||||||
@@ -90,6 +90,56 @@ async def active_core_stocks():
|
|||||||
conn.close()
|
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="指定交易日核心股(含所属题材)")
|
@router.get("/history", summary="指定交易日核心股(含所属题材)")
|
||||||
async def core_stock_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
|
async def core_stock_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
|
|||||||
+106
-50
@@ -21,7 +21,7 @@ from database import get_connection
|
|||||||
|
|
||||||
_CST = timezone(timedelta(hours=8))
|
_CST = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中发现投资机会。
|
SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中发现投资机会。为收盘后的每日复盘报告供稿。读者是有一定经验、但没时间盯盘的短线与波段投资者。
|
||||||
|
|
||||||
你的分析风格:
|
你的分析风格:
|
||||||
- 数据驱动,基于真实数据而非主观臆断
|
- 数据驱动,基于真实数据而非主观臆断
|
||||||
@@ -33,6 +33,7 @@ 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_consecutive_core_stocks: 获取最近N个交易日每天都上榜的核心股(缺一日都不行),用于识别持续活跃的热点股
|
||||||
- get_stock_quote: 获取个股实时行情
|
- get_stock_quote: 获取个股实时行情
|
||||||
- get_fund_flow: 获取个股资金流向
|
- get_fund_flow: 获取个股资金流向
|
||||||
- get_news: 获取财经快讯(新浪7x24,用于重要消息面)
|
- get_news: 获取财经快讯(新浪7x24,用于重要消息面)
|
||||||
@@ -41,62 +42,88 @@ SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中
|
|||||||
1. 你必须先调用工具获取数据,然后基于数据进行分析
|
1. 你必须先调用工具获取数据,然后基于数据进行分析
|
||||||
2. 不要凭空编造数据,所有数据必须来自工具返回
|
2. 不要凭空编造数据,所有数据必须来自工具返回
|
||||||
3. 如果工具返回空数据,如实说明数据不可用
|
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_report_section}
|
||||||
{prev_snapshot_section}
|
{prev_snapshot_section}
|
||||||
|
|
||||||
请先调用以下工具获取数据:
|
你的分析风格:
|
||||||
1. get_market_dashboard - 获取市场整体数据(含涨跌统计、市场温度、连板梯队 limitLadder、板块资金流 sectorFundFlow、两融、海外与期货 globalMarkets)
|
- 数据驱动,基于真实数据而非主观臆断
|
||||||
2. get_theme_history(date="{trade_date}") - 获取今日题材涨幅
|
- 逻辑清晰,先总后分,层层递进
|
||||||
3. get_active_core_stocks - 获取核心股数据
|
- 观点明确,给出具体的操作建议
|
||||||
4. get_news(limit=30) - 获取今日财经快讯
|
- 风险提示,每次推荐都需说明风险点
|
||||||
|
|
||||||
生成报告时必须遵守以下格式规则:
|
重要规则:
|
||||||
|
1. 你必须先调用工具获取数据,然后基于数据进行分析
|
||||||
|
2. 不要凭空编造数据,所有数据必须来自工具返回
|
||||||
|
3. 如果工具返回空数据,如实说明数据不可用
|
||||||
|
4. 分析完成后给出明确的结论和建议
|
||||||
|
|
||||||
1. 报告标题(# 一级标题)之后的第一行,必须是一个引用块"定调摘要",格式严格为:
|
■ 第一步 · 取数(必须一次并行完成)
|
||||||
|
在同一次回复中并行发起以下 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 次。
|
||||||
|
|
||||||
|
■ 第二步 · 写作(以下规约对每一部分都生效)
|
||||||
|
0. 报告标题(# 一级标题)之后的第一行,必须是一个引用块"定调摘要",格式严格为:
|
||||||
> 今日定调:<一句话核心结论,不超过80字,必须包含1-2个关键数字(如成交额、涨停家数、市场温度)>
|
> 今日定调:<一句话核心结论,不超过80字,必须包含1-2个关键数字(如成交额、涨停家数、市场温度)>
|
||||||
|
1. 事实约束:所有数字、个股代码与名称、涨停原因、封单金额必须出自工具返回,找不到就写「—(今日无数据)」,禁止推测或用常识填补;禁止用「预计/有望/大概率」代替数据。
|
||||||
2. 量能与情绪类数字必须给环比:若上方提供了"前一交易日盘面数据快照",成交额、涨跌家数、涨停数、两融余额、市场温度等在与昨日对比后表述(如"成交额2.05万亿,较昨日缩量约700亿");没有昨日快照则如实说明"暂无昨日数据"。
|
2. 环比约束:量能与情绪类数字必须给环比,若上方提供了"前一交易日盘面数据快照"(成交额、涨跌家数、涨停/跌停/炸板、市场温度、两融余额、主要指数),首次提到时必须写成「今日值(较昨日 ±变化)」;快照缺失时写「暂无昨日基准」,不得编造环比。
|
||||||
|
3. 单位口径(全文统一,表头标注单位):成交额→亿元(≥1万亿时写 x.xx 万亿);资金净额→亿元;封单金额→万元(limitLadder 的 sealWan 字段);指数→点。
|
||||||
3. 连板梯队必须完整呈现 get_market_dashboard 返回的 limitLadder:从最高连板到2连板逐级列表格,每只标注涨停原因(reason字段)与封单金额(sealWan,单位万,为空则不写);首板只挑3-5只人气最高的点评。
|
4. 符号规范:涨跌幅、环比变化、净流入/流出一律带 + 或 -,这是前端红涨绿跌的着色依据。
|
||||||
|
5. 连板梯队:连板梯队必须完整呈现 get_market_dashboard 返回的 limitLadder:从最高连板到2连板逐级列表格,每只标注涨停原因(reason字段)与封单金额(sealWan,单位万,为空则不写);首板只挑3-5只人气最高的点评。
|
||||||
4. 板块资金面必须引用 get_market_dashboard 返回的 sectorFundFlow:行业主力净流入TOP3、净流出TOP3、概念净流入TOP3(单位亿元),结合题材分析说明资金动向。
|
6. 板块资金:板块资金面必须引用 get_market_dashboard 返回的 sectorFundFlow:行业主力净流入TOP5、净流出TOP5、概念净流入TOP5(亿元,带符号),结合题材分析说明资金动向。
|
||||||
|
7. 海外市场与国内期货:必须引用 get_market_dashboard 返回的 globalMarkets:overseas 为海外主要指数(纳斯达克/道琼斯/标普500/恒生/日经/富时),futures 为国内期货主力合约(按成交额降序,已含价格与涨跌幅);点评与A股关联度高的品种(股指期货、原油、贵金属、黑色系),数据缺失则如实说明。
|
||||||
5. 海外市场与国内期货必须引用 get_market_dashboard 返回的 globalMarkets:overseas 为海外主要指数(纳斯达克/道琼斯/标普500/恒生/日经/富时),futures 为国内期货主力合约(按成交额降序,已含价格与涨跌幅);点评与A股关联度高的品种(股指期货、原油、贵金属、黑色系),数据缺失则如实说明。
|
8. 消息面:重要消息面必须基于 get_news 返回的快讯整理:挑5-8条对次日盘面影响最大的消息,每条格式为"【分类】一句话新闻 —— 一句影响解读"(分类用:宏观/政策/行业/公司/海外);快讯中若没有某方面的重要消息,如实说明「今日无重要消息」,严禁编造工具中不存在的新闻。
|
||||||
|
9. 适当使用表格展示数据对比。
|
||||||
6. 重要消息面必须基于 get_news 返回的快讯整理:挑5-8条对次日盘面影响最大的消息,每条格式为"【分类】一句话新闻 —— 一句影响解读"(分类用:宏观/政策/行业/公司/海外);快讯中若没有某方面的重要消息,如实说明,严禁编造工具中不存在的新闻。
|
基于数据生成报告。报告共八章,固定顺序:一、市场总览|二、题材热点分析|三、核心股追踪|四、资金与筹码|五、重要消息面|六、海外市场与国内期货|七、下个交易日建议|八、风险提示。
|
||||||
|
同一段数据只允许在它归属的那一章出现:指数与量能在第一章、题材涨幅在第二章、连板与核心股在第三章、资金流与两融在第四章,其他章节只做结论引用,不重复铺数据。
|
||||||
7. 适当使用表格展示数据对比。
|
系统会把八章拆成三次生成,每次只写被指定的部分。
|
||||||
|
"""
|
||||||
然后基于数据生成报告。报告共八章,将由系统分三次调用完成,每次调用只负责其中一部分,具体写作指令由后续消息给出。
|
|
||||||
|
|
||||||
请用 Markdown 格式输出,适当使用表格展示数据对比。"""
|
|
||||||
|
|
||||||
# 分段生成指令:网关对单次 LLM 请求有约120s硬超时,整篇报告一次生成必被掐断,
|
# 分段生成指令:网关对单次 LLM 请求有约120s硬超时,整篇报告一次生成必被掐断,
|
||||||
# 故拆为三段(每段约1200-1600字),各自独立调用后拼接
|
# 故拆为三段(每段约1200-1600字),各自独立调用后拼接
|
||||||
REPORT_PARTS = [
|
REPORT_PARTS = [
|
||||||
"""现在写报告的【第1部分】,只输出这一部分,直接输出 Markdown,不要任何开场白或说明:
|
"""现在写报告的【第1部分】,只输出这一部分,直接输出 Markdown,不要任何开场白或说明:
|
||||||
1. 以 `# {title} A股收盘分析报告` 一级标题开头
|
1. 以 `# {title} A股收盘分析报告` 一级标题开头
|
||||||
2. 标题后第一行输出定调引用块,格式严格为:`> 今日定调:<80字内核心结论,含1-2个关键数字>`
|
2. `## 一、市场总览`(指数表格、涨跌统计须环比、市场温度)
|
||||||
3. 写 `## 一、市场总览`(指数表格、涨跌统计须环比、市场温度)与 `## 二、题材热点分析`(涨幅前5、持续活跃、新兴热点、退潮警示、板块主力资金流TOP3)
|
3. `## 二、题材热点分析`(涨幅前5、持续活跃、新兴热点、退潮警示、板块主力资金流TOP3)
|
||||||
全文控制在1600字以内。""",
|
全文控制在1600字以内。""",
|
||||||
"""现在写报告的【第2部分】,只输出这一部分,直接输出 Markdown,不要重复之前内容:
|
"""现在写报告的【第2部分】,只输出这一部分,直接输出 Markdown,不要重复之前内容:
|
||||||
- `## 三、核心股追踪`(连板梯队完整表格:层级/股票/涨停原因/封单,首板挑3-5只人气股点评;核心股表现;龙头辨识)
|
- `## 三、核心股追踪`(连板梯队完整表格:层级/股票/涨停原因/封单,首板挑3-5只人气股点评;连续上榜股票表格:股票/连续天数/近3日涨幅,点评持续活跃的核心标的;核心股表现;龙头辨识)
|
||||||
- `## 四、关注方向`(明日题材方向、潜在交易机会)
|
- `## 四、资金与筹码`:sectorFundFlow 的行业净流入 TOP3 / 净流出 TOP3 / 概念净流入 TOP3(亿元,带符号)、两融余额与变化、事件情报中值得注意的筹码信号
|
||||||
|
全文控制在 1500 字以内。""",
|
||||||
|
"""前两部分已在上下文中,现在续写【第3部分】,直接输出 Markdown,不要复述前文、不要写过渡句:
|
||||||
|
- `## 五、重要消息面`:5-8条,格式【分类】新闻——影响解读
|
||||||
|
- `## 六、海外市场与国内期货`(点评对次日A股的影响)
|
||||||
全文控制在1300字以内。""",
|
全文控制在1300字以内。""",
|
||||||
"""现在写报告的【第3部分】,只输出这一部分,直接输出 Markdown,不要重复之前内容:
|
"""现在写报告的【第3部分】,只输出这一部分,直接输出 Markdown,不要重复之前内容:
|
||||||
- `## 五、下个交易日建议`(大盘预判、题材方向、核心股、仓位策略、规避方向)
|
- `## 七、下个交易日建议`(大盘预判、题材方向、核心股、仓位策略、潜在交易机会、规避方向,结合市场情绪、资金流向、热点题材、核心股表现、消息面、海外市场与国内期货、风险提示,给出明确的结论和建议。)
|
||||||
- `## 六、重要消息面`(5-8条,格式【分类】新闻——影响解读)
|
|
||||||
- `## 七、海外市场与国内期货`(点评对次日A股的影响)
|
|
||||||
- `## 八、风险提示`
|
- `## 八、风险提示`
|
||||||
全文控制在1900字以内。""",
|
全文控制在1900字以内。""",
|
||||||
]
|
]
|
||||||
|
## 五、下个交易日建议`(大盘预判、题材方向、核心股、仓位策略、规避方向)
|
||||||
# 报告头部的"今日定调"引用行,保存时提取为 summary
|
|
||||||
_TONE_LINE_RE = re.compile(r"^>\s*今日定调[::]\s*(.+)$", re.MULTILINE)
|
|
||||||
|
|
||||||
|
|
||||||
async def _consume_sse(resp: httpx.Response) -> dict:
|
async def _consume_sse(resp: httpx.Response) -> dict:
|
||||||
@@ -231,11 +258,14 @@ async def collect_ai_analysis(trade_date: str) -> dict:
|
|||||||
# ── 阶段一:工具轮(只获取数据,模型若直接开写报告则丢弃,由阶段二重写) ──
|
# ── 阶段一:工具轮(只获取数据,模型若直接开写报告则丢弃,由阶段二重写) ──
|
||||||
tools_used = []
|
tools_used = []
|
||||||
total_tokens = 0
|
total_tokens = 0
|
||||||
|
llm_calls = 0
|
||||||
was_truncated = False
|
was_truncated = False
|
||||||
max_rounds = 8
|
max_rounds = 8
|
||||||
|
tool_rounds = 0
|
||||||
|
|
||||||
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)
|
||||||
|
llm_calls += 1
|
||||||
total_tokens += response.get("usage", {}).get("total_tokens", 0)
|
total_tokens += response.get("usage", {}).get("total_tokens", 0)
|
||||||
|
|
||||||
choice = response["choices"][0]
|
choice = response["choices"][0]
|
||||||
@@ -246,6 +276,7 @@ async def collect_ai_analysis(trade_date: str) -> dict:
|
|||||||
f"tool_calls={len(message.get('tool_calls') or [])}")
|
f"tool_calls={len(message.get('tool_calls') or [])}")
|
||||||
|
|
||||||
if finish_reason == "tool_calls" and message.get("tool_calls"):
|
if finish_reason == "tool_calls" and message.get("tool_calls"):
|
||||||
|
tool_rounds += 1
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
for tool_call in message["tool_calls"]:
|
for tool_call in message["tool_calls"]:
|
||||||
func_name = tool_call["function"]["name"]
|
func_name = tool_call["function"]["name"]
|
||||||
@@ -257,7 +288,21 @@ async def collect_ai_analysis(trade_date: str) -> dict:
|
|||||||
"tool_call_id": tool_call["id"],
|
"tool_call_id": tool_call["id"],
|
||||||
"content": result
|
"content": result
|
||||||
})
|
})
|
||||||
break # 工具已齐,立即进入分段写作(再问一轮模型只会空转120s)
|
# 核心工具齐了就立即进分段写作(再问一轮只会空转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
|
||||||
|
|
||||||
# 非工具轮(模型直接开写/空返回):只要有工具结果就直接进入分段写作;
|
# 非工具轮(模型直接开写/空返回):只要有工具结果就直接进入分段写作;
|
||||||
# 一轮工具都没拿到则重试
|
# 一轮工具都没拿到则重试
|
||||||
@@ -281,6 +326,7 @@ async def collect_ai_analysis(trade_date: str) -> dict:
|
|||||||
"content": part_prompt.format(title=trade_date) if part_idx == 0 else part_prompt,
|
"content": part_prompt.format(title=trade_date) if part_idx == 0 else part_prompt,
|
||||||
}]
|
}]
|
||||||
response = await call_llm(part_messages)
|
response = await call_llm(part_messages)
|
||||||
|
llm_calls += 1
|
||||||
total_tokens += response.get("usage", {}).get("total_tokens", 0)
|
total_tokens += response.get("usage", {}).get("total_tokens", 0)
|
||||||
choice = response["choices"][0]
|
choice = response["choices"][0]
|
||||||
part_content = choice["message"].get("content") or ""
|
part_content = choice["message"].get("content") or ""
|
||||||
@@ -293,22 +339,26 @@ async def collect_ai_analysis(trade_date: str) -> dict:
|
|||||||
was_truncated = True
|
was_truncated = True
|
||||||
print(f"[ai-service] 警告:part {part_idx + 1} 三次尝试均失败")
|
print(f"[ai-service] 警告:part {part_idx + 1} 三次尝试均失败")
|
||||||
part_content = f"\n\n> (第{part_idx + 1}部分生成失败,请稍后重新生成)\n"
|
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
|
final_content += (final_content and "\n\n" or "") + part_content
|
||||||
|
|
||||||
summary = _extract_summary(final_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, llm_calls)
|
||||||
|
print(f"[ai-service] 生成完成:LLM调用 {llm_calls} 次,tokens {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, "llm_calls": llm_calls, "tools_used": tools_used, "truncated": was_truncated}
|
||||||
|
|
||||||
|
|
||||||
def _extract_summary(content: str) -> str:
|
def _extract_summary(content: str) -> str:
|
||||||
"""提取报告头部的"今日定调"引用行作为摘要;缺失时退回正文截断"""
|
"""摘要:去掉标题行与 Markdown 标记后截断 200 字(供管理列表展示)
|
||||||
match = _TONE_LINE_RE.search(content or "")
|
|
||||||
if match:
|
正文首行是 `# XXXX A股收盘分析报告`,直接截断会让它占掉摘要大半。
|
||||||
text = match.group(1).strip()
|
"""
|
||||||
if text:
|
text = re.sub(r"^#.*$", "", content or "", flags=re.MULTILINE)
|
||||||
return text[:200]
|
text = re.sub(r"[>#*`|]", "", text)
|
||||||
return (content or "")[:200].replace("\n", " ")
|
return re.sub(r"\s+", " ", text).strip()[:200]
|
||||||
|
|
||||||
|
|
||||||
def _num(v) -> str:
|
def _num(v) -> str:
|
||||||
@@ -425,14 +475,14 @@ def _get_prev_snapshot_section(trade_date: str) -> str:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def _save_report(trade_date: str, content: str, summary: str, tools_used: list, tokens_used: int) -> int:
|
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 记当次消耗)"""
|
"""保存报告到数据库(同日重生成:覆盖内容、generation_count+1、tokens 记当次消耗)"""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO ai_reports
|
"""INSERT INTO ai_reports
|
||||||
(trade_date, report_type, title, content, summary, tools_used, model, tokens_used, updated_at)
|
(trade_date, report_type, title, content, summary, tools_used, model, tokens_used, llm_calls, updated_at)
|
||||||
VALUES (?, 'daily', ?, ?, ?, ?, ?, ?, datetime('now','localtime'))
|
VALUES (?, 'daily', ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))
|
||||||
ON CONFLICT(trade_date, report_type) DO UPDATE SET
|
ON CONFLICT(trade_date, report_type) DO UPDATE SET
|
||||||
title = excluded.title,
|
title = excluded.title,
|
||||||
content = excluded.content,
|
content = excluded.content,
|
||||||
@@ -440,6 +490,7 @@ def _save_report(trade_date: str, content: str, summary: str, tools_used: list,
|
|||||||
tools_used = excluded.tools_used,
|
tools_used = excluded.tools_used,
|
||||||
model = excluded.model,
|
model = excluded.model,
|
||||||
tokens_used = excluded.tokens_used,
|
tokens_used = excluded.tokens_used,
|
||||||
|
llm_calls = excluded.llm_calls,
|
||||||
updated_at = excluded.updated_at,
|
updated_at = excluded.updated_at,
|
||||||
generation_count = ai_reports.generation_count + 1""",
|
generation_count = ai_reports.generation_count + 1""",
|
||||||
(
|
(
|
||||||
@@ -450,6 +501,7 @@ 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,
|
||||||
|
llm_calls,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -474,7 +526,11 @@ def _get_prev_report_section(trade_date: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
prev_date = row["trade_date"]
|
prev_date = row["trade_date"]
|
||||||
prev_content = row["content"] or ""
|
prev_content = row["content"] or ""
|
||||||
return f"""以下是前一个交易日({prev_date})的分析报告,请参考其中的分析逻辑和关注方向,结合今日数据进行对比分析:
|
return f"""以下是前一个交易日({prev_date})的分析报告,仅用于保持分析连续性:
|
||||||
|
|
||||||
|
- 今日数据优先:两者冲突时以今日工具数据为准,并在正文点出变化(如"昨日提示的××今日××")
|
||||||
|
- 禁止照搬昨日结论、禁止复制昨日段落
|
||||||
|
- 重点核对昨日"明日展望"与"风险提示"里提到的方向是否兑现;未兑现的要在正文中说明
|
||||||
|
|
||||||
{prev_content}
|
{prev_content}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ TOOLS = [
|
|||||||
"parameters": {"type": "object", "properties": {}, "required": []}
|
"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",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -98,6 +112,8 @@ async def execute_tool(tool_name: str, arguments: dict) -> str:
|
|||||||
return await _get_theme_history(arguments.get("date", ""))
|
return await _get_theme_history(arguments.get("date", ""))
|
||||||
elif tool_name == "get_active_core_stocks":
|
elif tool_name == "get_active_core_stocks":
|
||||||
return await _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":
|
elif tool_name == "get_stock_quote":
|
||||||
return await _get_stock_quote(arguments.get("code", ""))
|
return await _get_stock_quote(arguments.get("code", ""))
|
||||||
elif tool_name == "get_fund_flow":
|
elif tool_name == "get_fund_flow":
|
||||||
@@ -219,6 +235,66 @@ async def _get_active_core_stocks() -> str:
|
|||||||
conn.close()
|
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:
|
async def _get_stock_quote(code: str) -> str:
|
||||||
"""获取个股实时行情"""
|
"""获取个股实时行情"""
|
||||||
from services.tencent import fetch_quote
|
from services.tencent import fetch_quote
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface AiReport {
|
|||||||
model: string;
|
model: string;
|
||||||
tokens_used: number;
|
tokens_used: number;
|
||||||
generation_count?: number;
|
generation_count?: number;
|
||||||
|
llm_calls?: number | null;
|
||||||
issue_number?: number;
|
issue_number?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
@@ -34,6 +35,13 @@ export async function fetchAiReports(): Promise<AiReport[]> {
|
|||||||
return result.data || [];
|
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> {
|
export async function fetchAiReport(id: number): Promise<AiReport> {
|
||||||
const resp = await fetch(`${API_BASE}/api/ai-analysis/${id}`);
|
const resp = await fetch(`${API_BASE}/api/ai-analysis/${id}`);
|
||||||
if (!resp.ok) throw new Error(`请求失败 (${resp.status})`);
|
if (!resp.ok) throw new Error(`请求失败 (${resp.status})`);
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ export interface CoreStockHistoryResponse {
|
|||||||
items: CoreStockHistoryItem[];
|
items: CoreStockHistoryItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取活跃核心股 + 最近10日涨幅矩阵 */
|
/** 获取活跃核心股 + 最近N日涨幅矩阵 */
|
||||||
export async function fetchActiveCoreStocks(): Promise<ActiveCoreStocksResponse> {
|
export async function fetchActiveCoreStocks(days: number = 10): Promise<ActiveCoreStocksResponse> {
|
||||||
const baseUrl = getApiBaseUrl();
|
const baseUrl = getApiBaseUrl();
|
||||||
try {
|
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: [] };
|
if (!resp.ok) return { dates: [], stocks: [] };
|
||||||
return resp.json();
|
return resp.json();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+76
-31
@@ -1,15 +1,22 @@
|
|||||||
import * as React from "react";
|
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 { ArrowLeft, Clock, Loader2, AlertCircle, Wrench, Calendar } from "lucide-react";
|
||||||
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 type { Components } from "react-markdown";
|
||||||
import { Mermaid } from "../components/Mermaid";
|
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";
|
import { Card, CardContent } from "../components/ui/card";
|
||||||
|
|
||||||
export const Route = createFileRoute("/ai-analysis")({
|
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,
|
component: AiAnalysisPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,7 +53,7 @@ function colorizeChildren(children: React.ReactNode): React.ReactNode {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按 "## " 二级标题拆分报告为多张卡片;首段(# 标题 + 定调引用块)单独一张 */
|
/** 按 "## " 二级标题拆分报告为多张卡片;首段(# 标题等)若无正文则不单独成卡 */
|
||||||
function splitReport(content: string): { intro: string; sections: { title: string; body: string }[] } {
|
function splitReport(content: string): { intro: string; sections: { title: string; body: string }[] } {
|
||||||
const parts = content.split(/\n(?=## )/);
|
const parts = content.split(/\n(?=## )/);
|
||||||
const sections = parts.slice(1).map((p) => {
|
const sections = parts.slice(1).map((p) => {
|
||||||
@@ -55,16 +62,36 @@ function splitReport(content: string): { intro: string; sections: { title: strin
|
|||||||
const body = nl === -1 ? "" : p.slice(nl + 1).trim();
|
const body = nl === -1 ? "" : p.slice(nl + 1).trim();
|
||||||
return { title, body };
|
return { title, body };
|
||||||
});
|
});
|
||||||
return { intro: parts[0].trim(), sections };
|
const intro = parts[0].trim();
|
||||||
|
// intro 只剩标题(无其他内容)时置空,避免渲染一张只有大标题的空卡片
|
||||||
|
const introBody = intro.replace(/^#[^\n]*$/m, "").trim();
|
||||||
|
return { intro: introBody ? intro : "", sections };
|
||||||
}
|
}
|
||||||
|
|
||||||
function AiAnalysisPage() {
|
function AiAnalysisPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { date, data } = Route.useSearch();
|
||||||
|
const tradeDate = date ?? data ?? undefined;
|
||||||
|
const isHistorical = !!tradeDate;
|
||||||
|
|
||||||
const { data: report, isLoading, isError } = useQuery({
|
const { data: report, isLoading, isError } = useQuery({
|
||||||
queryKey: ["ai-report-latest"],
|
queryKey: isHistorical ? ["ai-report", tradeDate] : ["ai-report-latest"],
|
||||||
queryFn: fetchAiLatestReport,
|
queryFn: () => (isHistorical ? fetchAiReportByDate(tradeDate!) : fetchAiLatestReport()),
|
||||||
retry: false,
|
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 = {
|
const mdComponents: Components = {
|
||||||
table({ children, ...props }) {
|
table({ children, ...props }) {
|
||||||
return (
|
return (
|
||||||
@@ -93,17 +120,34 @@ function AiAnalysisPage() {
|
|||||||
<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">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 sm:gap-3 mb-4 sm:mb-6">
|
<div className="flex items-center justify-between gap-2 sm:gap-3 mb-4 sm:mb-6">
|
||||||
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors">
|
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors shrink-0">
|
||||||
</Link>
|
<ArrowLeft className="h-5 w-5" />
|
||||||
<h1 className="text-lg sm:text-xl md:text-2xl font-bold flex items-center gap-2">
|
</Link>
|
||||||
<Calendar className="h-5 w-5 text-primary" />
|
<h1 className="text-lg sm:text-xl md:text-2xl font-bold flex items-center gap-2 truncate">
|
||||||
AI 收盘分析
|
<Calendar className="h-5 w-5 text-primary shrink-0" />
|
||||||
{report?.issue_number ? (
|
AI 收盘分析
|
||||||
<span className="text-xs font-normal text-muted-foreground">总第 {report.issue_number} 期</span>
|
{report?.issue_number ? (
|
||||||
) : null}
|
<span className="text-xs font-normal text-muted-foreground whitespace-nowrap">总第 {report.issue_number} 期</span>
|
||||||
</h1>
|
) : 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>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -115,8 +159,20 @@ function AiAnalysisPage() {
|
|||||||
) : isError || !report ? (
|
) : isError || !report ? (
|
||||||
<div className="flex flex-col items-center gap-3 py-20">
|
<div className="flex flex-col items-center gap-3 py-20">
|
||||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||||
<p className="text-sm text-muted-foreground">暂无分析报告</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground/60">请通过管理面板触发 AI 分析</p>
|
{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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -129,6 +185,7 @@ function AiAnalysisPage() {
|
|||||||
<span className="truncate">{report.updated_at || 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.llm_calls ? <span>{report.llm_calls} 次 LLM 调用</span> : null}
|
||||||
{(report.generation_count ?? 1) > 1 && (
|
{(report.generation_count ?? 1) > 1 && (
|
||||||
<span>第 {report.generation_count} 次生成</span>
|
<span>第 {report.generation_count} 次生成</span>
|
||||||
)}
|
)}
|
||||||
@@ -142,18 +199,6 @@ function AiAnalysisPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 今日定调摘要 */}
|
|
||||||
{report.summary && (
|
|
||||||
<div className="mb-3 sm:mb-4 rounded-lg border border-primary/30 bg-primary/5 px-3 sm:px-4 py-3">
|
|
||||||
<div className="flex items-start gap-2.5">
|
|
||||||
<span className="shrink-0 mt-0.5 rounded bg-primary/10 px-1.5 py-0.5 text-[11px] font-semibold text-primary">
|
|
||||||
今日定调
|
|
||||||
</span>
|
|
||||||
<p className="text-sm leading-relaxed">{report.summary}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 报告正文:按 ## 章节分卡片渲染 */}
|
{/* 报告正文:按 ## 章节分卡片渲染 */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const { intro, sections } = splitReport(report.content);
|
const { intro, sections } = splitReport(report.content);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Fragment } from "react";
|
import { Fragment, useState } from "react";
|
||||||
import { fetchActiveCoreStocks } from "@/lib/core-stock-api";
|
import { fetchActiveCoreStocks } from "@/lib/core-stock-api";
|
||||||
import { ArrowLeft, RefreshCw, Flame } from "lucide-react";
|
import { ArrowLeft, RefreshCw, Flame } from "lucide-react";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
export const Route = createFileRoute("/core-stocks")({
|
export const Route = createFileRoute("/core-stocks")({
|
||||||
component: CoreStocksPage,
|
component: CoreStocksPage,
|
||||||
@@ -16,9 +17,10 @@ function formatGain(v: number | null | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CoreStocksPage() {
|
function CoreStocksPage() {
|
||||||
|
const [days, setDays] = useState(10);
|
||||||
const { data, isLoading, isFetching, refetch } = useQuery({
|
const { data, isLoading, isFetching, refetch } = useQuery({
|
||||||
queryKey: ["core-stocks", "active"],
|
queryKey: ["core-stocks", "active", days],
|
||||||
queryFn: fetchActiveCoreStocks,
|
queryFn: () => fetchActiveCoreStocks(days),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
@@ -48,9 +50,18 @@ function CoreStocksPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
|
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
|
||||||
<p className="text-[10px] text-muted-foreground mb-2">
|
<div className="flex items-center justify-between mb-3">
|
||||||
活跃热点股(最近 10 个交易日内上榜)· 按上榜次数排序 · 共 {stocks.length} 只
|
<p className="text-[10px] text-muted-foreground">
|
||||||
</p>
|
活跃热点股(最近 {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 ? (
|
{isLoading ? (
|
||||||
<div className="animate-pulse rounded-xl bg-muted h-32" />
|
<div className="animate-pulse rounded-xl bg-muted h-32" />
|
||||||
|
|||||||
Reference in New Issue
Block a user