- 策略: 引擎只加载 data/strategies/{custom,ai,composite} 与研究模板目录,
原 25 个内置策略一次性迁移为用户自定义文件(ID 不变, 引用零迁移);
研究模板移至 app/strategy/research, 打包 spec 同步; 新装环境策略库为空
- 信号: 管线不再硬编码任何信号表达式; 涨跌停判定价成为运行时列
limit_up_price/limit_down_price(生效价∓0.005 容差), 20 个信号转为
data/user_data/custom_signals 定义(显式 column 沿用 signal_* 原列名);
依赖解析/注入顺序/当日路径 prev-join 全部定义驱动; 语义统一为历史路径
口径(顺带修正当日路径 BOLL 比较符与翘板/炸板空值两处既有偏差)
- 前端: 信号库单列表(全部可编辑), SignalPicker/监控选项/信号标签全走
定义接口; 回测页新增免责声明(模拟性质/数据源准确性不作保证/不构成
投资建议)
- 测试: 25 个策略夹具与 20 个信号种子入库 tests/fixtures, 受影响测试
全部自包含化(不依赖运行目录用户数据); 77 万行 MA 死叉新旧表达式对拍
逐行一致; 回测实跑 45 笔卖点信号出场验证矩阵链路
- 同批包含工作区既有进度: 策略结果 ext 列同日投影修复(含测试)、数据源插件、fuyao/auth/api_gateway/data_integrity 调整、README 信号库
描述与 strategy.md 导入路径补充
验证: 后端 2592 passed(空数据目录 CI 模拟 2588 passed, 4 例为模拟环境
伪影复验排除); 前端 vitest 125 passed + tsc 0 错 + vite build 通过
1540 lines
66 KiB
Python
1540 lines
66 KiB
Python
"""AI 助手工具层 — 核心工具目录透传 + 面向各页面能力的本地查询/动作工具。
|
|
|
|
设计原则(为项目服务、有界):
|
|
- 查询工具对应页面核心查询能力(行情/自选/看板/指数/板块轮动/市场
|
|
环境/异动/持仓提醒/信号库/财务/个股分析/因子值/跑策略), 全部只读。
|
|
- 动作工具(create_signal_strategy/add_to_watchlist, 及核心目录的
|
|
run_backtest)有写副作用或重计算, 由 chat_service 的确认闸门把守
|
|
(actions.py), 未经用户确认不会执行。
|
|
- 返回体有界: rows 全部带 limit 钳制, 大表(enriched/财务)先 filter 再取
|
|
少量行; 数值统一四舍五入 4 位, 日期转 ISO 字符串, 保证 JSON 可序列化。
|
|
- 未知的工具名回退到核心 services.tool_catalog(因子/策略/数据能力/回测)。
|
|
|
|
本模块还提供:
|
|
- QUICK_SUGGESTS: 空会话快捷指令。
|
|
- summarize_tool_result: 工具结果的足迹卡一行摘要(不回传原始大 JSON)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import math
|
|
import re
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from datetime import date, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import polars as pl
|
|
|
|
from app.market_time import cn_today
|
|
from app.services import tool_catalog
|
|
|
|
# ----------------------------------------------------------------
|
|
# 快捷指令: label 展示、prompt 为点击后直接发送的完整问题;
|
|
# group 为前端空会话页的分组标题(同组相邻展示)。
|
|
# ----------------------------------------------------------------
|
|
QUICK_SUGGESTS: list[dict[str, str]] = [
|
|
# — 行情与大盘 —
|
|
{
|
|
"id": "market-overview",
|
|
"group": "行情与大盘",
|
|
"label": "今天市场怎么样?", # noqa: RUF001 — 面向用户中文文案含全角问号
|
|
"prompt": "帮我看看今天的市场总览: 涨跌家数、成交额、涨停与连板梯队、情绪雷达, 以及领涨领跌板块。",
|
|
},
|
|
{
|
|
"id": "sector-rotation",
|
|
"group": "行情与大盘",
|
|
"label": "板块在怎么轮动",
|
|
"prompt": "看看今天概念/行业板块的轮动: 领涨领跌板块、盘中切换事件和资金排名, 简要总结主线。",
|
|
},
|
|
{
|
|
"id": "abnormal-check",
|
|
"group": "行情与大盘",
|
|
"label": "今天有什么异动",
|
|
"prompt": "今天市场上有哪些异动? 快速拉升、大笔买卖、涨跌停开板等都列一下, 按重要程度总结。",
|
|
},
|
|
{
|
|
"id": "regime-check",
|
|
"group": "行情与大盘",
|
|
"label": "当前市场环境",
|
|
"prompt": "最近一个月市场环境(regime)状态如何演变?今天是强势还是弱势, 情绪周期处于什么阶段?", # noqa: RUF001
|
|
},
|
|
{
|
|
"id": "indices-check",
|
|
"group": "行情与大盘",
|
|
"label": "主要指数表现",
|
|
"prompt": "查一下主要指数今天的行情: 上证指数、深证成指、创业板指的涨跌幅与成交额, 对比一下谁强谁弱。",
|
|
},
|
|
# — 我的与个股 —
|
|
{
|
|
"id": "watchlist-check",
|
|
"group": "我的与个股",
|
|
"label": "我的自选表现如何",
|
|
"prompt": "查一下我的自选列表, 按今日涨跌幅排序, 标出表现最好和最差的, 并简要点评。",
|
|
},
|
|
{
|
|
"id": "stock-analyze",
|
|
"group": "我的与个股",
|
|
"label": "分析一只个股",
|
|
"prompt": "帮我分析 600519.SH: 结合分时与日K看最新走势, 给出关键价位(支撑/压力)、趋势状态和量价特征。",
|
|
},
|
|
{
|
|
"id": "stock-compare",
|
|
"group": "我的与个股",
|
|
"label": "横向对比几只股票",
|
|
"prompt": "横向对比 600519.SH、000858.SZ、603288.SH: 实时行情与换手率放一张表, 结合关键价位分析说说各自强弱和更适合关注哪只。",
|
|
},
|
|
{
|
|
"id": "quote-batch",
|
|
"group": "我的与个股",
|
|
"label": "查几只股票的行情",
|
|
"prompt": "查一下 600519.SH、000001.SZ、300750.SZ 的实时行情快照, 按涨跌幅排序并点评一句。",
|
|
},
|
|
{
|
|
"id": "financials-check",
|
|
"group": "我的与个股",
|
|
"label": "个股财务速览",
|
|
"prompt": "看看 600519.SH 的财务: 最新一期的营收、净利润与同比变化, ROE 和毛利率处于什么水平。",
|
|
},
|
|
# — 策略与信号 —
|
|
{
|
|
"id": "list-strategies",
|
|
"group": "策略与信号",
|
|
"label": "我有哪些策略?", # noqa: RUF001
|
|
"prompt": "我目前有哪些策略?分别简述每个策略是做什么的、适用什么资产。", # noqa: RUF001
|
|
},
|
|
{
|
|
"id": "strategy-compare",
|
|
"group": "策略与信号",
|
|
"label": "对比两个策略",
|
|
"prompt": "列出我的策略, 挑两个有代表性的各回测最近一年(每次执行前请在确认卡上点「确认」), 然后横向对比收益、回撤、夏普、胜率, 给出各自适合的市场场景。",
|
|
},
|
|
{
|
|
"id": "run-strategy-today",
|
|
"group": "策略与信号",
|
|
"label": "跑一个策略",
|
|
"prompt": "执行我的第一个策略, 看看按最新数据选出哪些标的, 并简要点评排在前面的几只。",
|
|
},
|
|
{
|
|
"id": "backtest-pick",
|
|
"group": "策略与信号",
|
|
"label": "回测我的策略",
|
|
"prompt": "先用工具看看我有哪些策略, 然后挑一个回测最近半年(执行前请在确认卡上点「确认」), 给出收益、回撤、夏普和胜率的解读。",
|
|
},
|
|
{
|
|
"id": "create-signal",
|
|
"group": "策略与信号",
|
|
"label": "帮我生成一个信号",
|
|
"prompt": "帮我在信号库里创建一个「放量站上20日线」的信号: 收盘价高于 ma20、1 日前收盘价不高于 1 日前的 ma20, 且成交量高于 5 日均量的 1.5 倍。先说明条件设计, 我确认后再执行。",
|
|
},
|
|
# — 数据与扩展 —
|
|
{
|
|
"id": "data-coverage",
|
|
"group": "数据与扩展",
|
|
"label": "数据是最新的吗",
|
|
"prompt": "帮我检查本地数据完整性: 日线/指标/分钟K/财务是不是最新、有没有缺口, 有问题告诉我怎么补全, 需要执行时先给我确认。",
|
|
},
|
|
{
|
|
"id": "ext-tables",
|
|
"group": "数据与扩展",
|
|
"label": "我的扩展数据表",
|
|
"prompt": "列出我配置的扩展数据表: 每张表的模式、字段和数据日期; 挑数据最新的一张看看前 10 行内容。",
|
|
},
|
|
{
|
|
"id": "sync-status",
|
|
"group": "数据与扩展",
|
|
"label": "有没有任务在跑",
|
|
"prompt": "现在有数据同步任务在跑吗? 有的话看看是什么任务、进度到哪了, 顺便告诉我数据是否完整。",
|
|
},
|
|
]
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# 执行上下文 — 由路由从 app.state 注入, 测试可替换为桩对象。
|
|
# ----------------------------------------------------------------
|
|
@dataclass
|
|
class ToolContext:
|
|
repo: Any = None
|
|
quote_service: Any = None
|
|
depth_service: Any = None
|
|
engine: Any = None
|
|
data_dir: Path | None = None
|
|
capabilities: Any = None
|
|
financial_scheduler: Any = None
|
|
|
|
@classmethod
|
|
def build(
|
|
cls,
|
|
*,
|
|
repo: Any = None,
|
|
quote_service: Any = None,
|
|
depth_service: Any = None,
|
|
engine: Any = None,
|
|
data_dir: str | Path | None = None,
|
|
capabilities: Any = None,
|
|
financial_scheduler: Any = None,
|
|
) -> ToolContext:
|
|
return cls(
|
|
repo=repo,
|
|
quote_service=quote_service,
|
|
depth_service=depth_service,
|
|
engine=engine,
|
|
data_dir=Path(data_dir) if data_dir else None,
|
|
capabilities=capabilities,
|
|
financial_scheduler=financial_scheduler,
|
|
)
|
|
|
|
|
|
_SYMBOL_RE = re.compile(r"^[0-9A-Za-z._-]{3,16}$")
|
|
|
|
|
|
def _clamp(value: Any, low: int, high: int, default: int) -> int:
|
|
try:
|
|
number = int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return max(low, min(high, number))
|
|
|
|
|
|
def _clean_value(value: Any) -> Any:
|
|
"""JSON 序列化清洗: 日期转 ISO, 浮点四舍五入并剔除 nan/inf。"""
|
|
if isinstance(value, (date, datetime)):
|
|
return value.isoformat()
|
|
if isinstance(value, float):
|
|
if math.isnan(value) or math.isinf(value):
|
|
return None
|
|
return round(value, 4)
|
|
return value
|
|
|
|
|
|
def _clean_obj(value: Any) -> Any:
|
|
"""递归清洗 dict/list 结构(run_strategy 行等动态载荷)。"""
|
|
if isinstance(value, dict):
|
|
return {str(k): _clean_obj(v) for k, v in value.items()}
|
|
if isinstance(value, (list, tuple)):
|
|
return [_clean_obj(v) for v in value]
|
|
return _clean_value(value)
|
|
|
|
|
|
def _rows(
|
|
df: pl.DataFrame | None,
|
|
limit: int,
|
|
columns: list[str] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""DataFrame → 有界的 JSON 行列表; 空/缺列安全。"""
|
|
if df is None or df.is_empty():
|
|
return []
|
|
if columns:
|
|
keep = [c for c in columns if c in df.columns]
|
|
if keep:
|
|
df = df.select(keep)
|
|
if df.width > 60:
|
|
df = df.select(df.columns[:60])
|
|
rows: list[dict[str, Any]] = []
|
|
for row in df.head(limit).to_dicts():
|
|
rows.append({key: _clean_value(value) for key, value in row.items()})
|
|
return rows
|
|
|
|
|
|
_QUOTE_COLUMNS = (
|
|
"symbol", "close", "open", "high", "low", "volume", "amount",
|
|
"prev_close", "change_pct", "change_amount", "amplitude", "turnover_rate",
|
|
)
|
|
|
|
|
|
def _asset_quote_frame(ctx: ToolContext, symbols: list[str]) -> pl.DataFrame:
|
|
"""symbols 的行情快照: 股票取 get_quotes_compat, ETF / 指数取各自的 enriched 缓存。
|
|
|
|
get_quotes_compat 只含股票 enriched 缓存; ETF 与指数是独立缓存, 与自选页
|
|
/api/watchlist/enriched 一样须按资产类型分流, 否则 ETF / 指数永远查不到价格。
|
|
只对股票缓存里没有的代码判定资产类型, 全是股票时不加载 ETF / 指数缓存。
|
|
"""
|
|
frames: list[pl.DataFrame] = []
|
|
df = ctx.quote_service.get_quotes_compat()
|
|
if df is not None and not df.is_empty() and "symbol" in df.columns:
|
|
frames.append(df.filter(pl.col("symbol").is_in(symbols)))
|
|
found = set(frames[0]["symbol"].to_list()) if frames else set()
|
|
missing = [s for s in symbols if s not in found]
|
|
if missing and ctx.repo is not None:
|
|
by_asset: dict[str, list[str]] = {}
|
|
for symbol in missing:
|
|
asset_type = ctx.repo.resolve_asset_type(symbol)
|
|
if asset_type != "stock":
|
|
by_asset.setdefault(asset_type, []).append(symbol)
|
|
for asset_type, members in by_asset.items():
|
|
asset_df, _ = ctx.repo.get_enriched_latest_asset(asset_type)
|
|
if asset_df is None or asset_df.is_empty() or "symbol" not in asset_df.columns:
|
|
continue
|
|
keep = [c for c in _QUOTE_COLUMNS if c in asset_df.columns]
|
|
frames.append(asset_df.filter(pl.col("symbol").is_in(members)).select(keep))
|
|
if not frames:
|
|
return pl.DataFrame()
|
|
return pl.concat(frames, how="diagonal_relaxed")
|
|
|
|
|
|
def _quotes_map(ctx: ToolContext, symbols: list[str]) -> dict[str, dict[str, Any]]:
|
|
"""symbol → {close, change_pct} 轻量行情映射(自选/持仓等合并展示用)。"""
|
|
if ctx.quote_service is None or not symbols:
|
|
return {}
|
|
try:
|
|
df = _asset_quote_frame(ctx, symbols)
|
|
except Exception: # 行情服务未就绪时静默降级为无价格列
|
|
return {}
|
|
if df is None or df.is_empty() or "symbol" not in df.columns:
|
|
return {}
|
|
keep = [c for c in ("symbol", "close", "change_pct") if c in df.columns]
|
|
out: dict[str, dict[str, Any]] = {}
|
|
for row in df.filter(pl.col("symbol").is_in(symbols)).select(keep).to_dicts():
|
|
out[row["symbol"]] = {k: _clean_value(v) for k, v in row.items() if k != "symbol"}
|
|
return out
|
|
|
|
|
|
def _require_repo(ctx: ToolContext) -> Any:
|
|
if ctx.repo is None:
|
|
raise ValueError("行情数据仓库未就绪, 请确认后端已完成启动。")
|
|
return ctx.repo
|
|
|
|
|
|
def _validate_symbol(value: Any) -> str:
|
|
symbol = str(value or "").strip()
|
|
if not _SYMBOL_RE.fullmatch(symbol):
|
|
raise ValueError(f"无效的证券代码: {symbol!r} (示例: 600519.SH / 300750.SZ)")
|
|
return symbol
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# 本地查询工具实现 (全部同步, 由 execute_assistant_tool 挪出事件循环)。
|
|
# ----------------------------------------------------------------
|
|
def _get_stock_quote(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
if ctx.quote_service is None:
|
|
raise ValueError("实时行情服务未启用, 无法查询个股快照。")
|
|
raw = args.get("symbols") or []
|
|
if not isinstance(raw, list) or not raw:
|
|
raise ValueError("symbols 不能为空, 请传入证券代码数组 (如 ['600519.SH'])。")
|
|
symbols = list(dict.fromkeys(_validate_symbol(s) for s in raw[:50]))
|
|
sub = _asset_quote_frame(ctx, symbols)
|
|
names = ctx.repo.get_name_map(symbols) if ctx.repo is not None else {}
|
|
rows = _rows(sub, 50)
|
|
for row in rows:
|
|
row.setdefault("name", names.get(row.get("symbol"), ""))
|
|
if not rows:
|
|
return {"count": 0, "rows": [], "note": "未匹配到行情, 请确认代码格式 (如 600519.SH)。"}
|
|
result: dict[str, Any] = {"count": len(rows), "rows": rows}
|
|
# 单只查询时附当日分时小图; 批量查询 N 只附 N 张图会淹没回答, 不附。
|
|
if len(rows) == 1:
|
|
row = rows[0]
|
|
prev_close = row.get("prev_close")
|
|
if prev_close is None and row.get("close") is not None and row.get("change_pct") is not None:
|
|
# 行情快照无昨收列时按 close/(1+pct) 反推, 供分时图画昨收基准线
|
|
prev_close = round(float(row["close"]) / (1.0 + float(row["change_pct"])), 3)
|
|
chart = _intraday_chart_payload(ctx, symbols[0], str(row.get("name") or ""), prev_close)
|
|
if chart is not None:
|
|
result["charts"] = [chart]
|
|
return result
|
|
|
|
|
|
def _finite_float(value: Any) -> float | None:
|
|
"""转 float; None / 非数字 / nan / inf 返回 None, 避免写入非法 JSON。"""
|
|
if value is None:
|
|
return None
|
|
try:
|
|
number = float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if not math.isfinite(number):
|
|
return None
|
|
return number
|
|
|
|
|
|
def _num(v: Any) -> float | None:
|
|
number = _finite_float(v)
|
|
return None if number is None else round(number, 3)
|
|
|
|
|
|
def _intraday_chart_payload(
|
|
ctx: ToolContext, symbol: str, name: str, prev_close: float | None = None,
|
|
) -> dict[str, Any] | None:
|
|
"""当日分时小图 payload — 本地分钟K收盘序列, 非模型生成(结果可核对)。
|
|
|
|
点数封顶 240(一个交易日); 本地无分钟数据时返回 None, 不阻断行情回答。
|
|
"""
|
|
if ctx.repo is None:
|
|
return None
|
|
try:
|
|
asset_type = ctx.repo.resolve_asset_type(symbol)
|
|
# 取行情快照所在交易日 (昨收基准线也来自快照行): 周末/节假日/开盘前快照停在
|
|
# 最近交易日, 按服务器本地 date.today() 查分钟分区会永远为空; 无快照日期时按北京日期。
|
|
_, trade_date = ctx.repo.get_enriched_latest_asset(asset_type, refresh=False)
|
|
df = ctx.repo.get_minute(symbol, trade_date or cn_today(), asset_type)
|
|
except Exception:
|
|
return None
|
|
if df is None or df.is_empty() or "close" not in df.columns or "datetime" not in df.columns:
|
|
return None
|
|
points: list[list[Any]] = []
|
|
for row in df.sort("datetime").to_dicts():
|
|
dt = row.get("datetime")
|
|
close = _finite_float(row.get("close"))
|
|
if dt is None or close is None:
|
|
continue
|
|
t = dt.strftime("%H:%M") if hasattr(dt, "strftime") else str(dt)[-8:-3]
|
|
volume = _finite_float(row.get("volume"))
|
|
points.append([t, round(close, 3), 0.0 if volume is None else round(volume, 2)])
|
|
if len(points) < 2:
|
|
return None
|
|
payload: dict[str, Any] = {
|
|
"kind": "intraday",
|
|
"symbol": symbol,
|
|
"name": name,
|
|
"points": points[-240:],
|
|
}
|
|
if prev_close is not None:
|
|
payload["prev_close"] = prev_close
|
|
return payload
|
|
|
|
|
|
def _daily_kline_chart_payload(symbol: str, name: str, rows: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
"""日K 小图 payload — [date, open, high, low, close, volume], 前端渲染蜡烛图。
|
|
|
|
点数封顶 120, 避免长区间把 NDJSON 流式事件撑大; 不足 2 根不附图。
|
|
"""
|
|
points = [
|
|
[
|
|
str(row["date"]), _num(row.get("open")), _num(row.get("high")),
|
|
_num(row.get("low")), _num(row.get("close")), _num(row.get("volume")),
|
|
]
|
|
for row in rows
|
|
if row.get("date") is not None and row.get("close") is not None and row.get("open") is not None
|
|
]
|
|
if len(points) < 2:
|
|
return None
|
|
return {"kind": "daily_kline", "symbol": symbol, "name": name, "points": points[-120:]}
|
|
|
|
|
|
def _get_stock_daily(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
repo = _require_repo(ctx)
|
|
symbol = _validate_symbol(args.get("symbol"))
|
|
days = _clamp(args.get("days"), 10, 500, 60)
|
|
end = cn_today()
|
|
start = end - timedelta(days=int(days * 1.9) + 20)
|
|
df = repo.get_daily_asset(repo.resolve_asset_type(symbol), symbol, start, end)
|
|
if df is None or df.is_empty():
|
|
return {"symbol": symbol, "rows": [], "note": "本地没有该标的的日线数据。"}
|
|
keep = [
|
|
c for c in (
|
|
"date", "open", "high", "low", "close", "volume", "amount",
|
|
"change_pct", "turnover_rate", "amplitude", "ma5", "ma10", "ma20", "ma60",
|
|
) if c in df.columns
|
|
]
|
|
rows = _rows(df.select(keep).tail(days), days)
|
|
names = repo.get_name_map([symbol])
|
|
name = names.get(symbol, "")
|
|
result: dict[str, Any] = {
|
|
"symbol": symbol,
|
|
"name": name,
|
|
"count": len(rows),
|
|
"rows": rows,
|
|
}
|
|
kline = _daily_kline_chart_payload(symbol, name, rows)
|
|
if kline is not None:
|
|
result["charts"] = [kline]
|
|
return result
|
|
|
|
|
|
def _get_stock_analysis(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
from app.indicators.levels import compute_levels, summarize_levels
|
|
|
|
repo = _require_repo(ctx)
|
|
symbol = _validate_symbol(args.get("symbol"))
|
|
end = cn_today()
|
|
df = repo.get_daily_asset(repo.resolve_asset_type(symbol), symbol, end - timedelta(days=500), end)
|
|
if df is None or df.is_empty():
|
|
return {"symbol": symbol, "note": "本地没有该标的的日线数据, 无法分析。"}
|
|
recent = df.tail(120)
|
|
close = float(recent["close"][-1]) if recent.height else None
|
|
levels = compute_levels(recent)
|
|
keep = [
|
|
c for c in (
|
|
"date", "close", "change_pct", "amount", "turnover_rate", "amplitude",
|
|
"ma5", "ma10", "ma20", "ma60", "high_60d", "low_60d",
|
|
) if c in recent.columns
|
|
]
|
|
latest_rows = _rows(recent.select(keep).tail(1), 1)
|
|
names = repo.get_name_map([symbol])
|
|
return {
|
|
"symbol": symbol,
|
|
"name": names.get(symbol, ""),
|
|
"close": _clean_value(close),
|
|
"summary": summarize_levels(levels, close),
|
|
"latest": latest_rows[0] if latest_rows else {},
|
|
"levels": _clean_obj(levels),
|
|
}
|
|
|
|
|
|
def _get_financials(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
from app.services.financial_sync import FINANCIAL_TABLES, get_financial_df
|
|
|
|
symbol = _validate_symbol(args.get("symbol"))
|
|
table = str(args.get("table") or "metrics")
|
|
if table not in FINANCIAL_TABLES:
|
|
raise ValueError(f"table 必须是 {list(FINANCIAL_TABLES)} 之一。")
|
|
periods = _clamp(args.get("periods"), 1, 8, 4)
|
|
if ctx.data_dir is None:
|
|
raise ValueError("数据目录未就绪。")
|
|
df = get_financial_df(ctx.data_dir, table)
|
|
if df is None or df.is_empty():
|
|
return {"symbol": symbol, "table": table, "rows": [], "note": "本地暂无财务数据, 可在数据页同步。"}
|
|
sub = df.filter(pl.col("symbol") == symbol)
|
|
if sub.is_empty():
|
|
return {"symbol": symbol, "table": table, "rows": [], "note": "本地财务数据中没有该标的。"}
|
|
# 本地财务表的报告期列是 period_end; 物理行序取决于同步路径, 须显式排序后再取最近 N 期
|
|
sort_col = next((c for c in ("period_end", "report_date", "end_date", "ann_date", "date") if c in sub.columns), None)
|
|
if sort_col:
|
|
sub = sub.sort(sort_col)
|
|
sub = sub.tail(periods)
|
|
if sub.height:
|
|
nulls = dict(zip(sub.columns, sub.null_count().row(0), strict=True))
|
|
keep = [c for c in sub.columns if nulls.get(c, 0) < sub.height]
|
|
if keep:
|
|
sub = sub.select(keep)
|
|
rows = _rows(sub, periods)
|
|
return {"symbol": symbol, "table": table, "count": len(rows), "rows": rows}
|
|
|
|
|
|
def _get_watchlist(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
from app.services import watchlist as watchlist_service
|
|
|
|
items = watchlist_service.list_symbols()
|
|
if not items:
|
|
return {"count": 0, "rows": [], "note": "自选列表为空。"}
|
|
symbols = [str(i.get("symbol") or "") for i in items if i.get("symbol")]
|
|
quotes = _quotes_map(ctx, symbols)
|
|
names = ctx.repo.get_name_map(symbols) if ctx.repo is not None else {}
|
|
rows = []
|
|
for item in items[:300]:
|
|
symbol = str(item.get("symbol") or "")
|
|
quote = quotes.get(symbol, {})
|
|
rows.append({
|
|
"symbol": symbol,
|
|
"name": names.get(symbol, ""),
|
|
"note": item.get("note") or "",
|
|
"close": quote.get("close"),
|
|
"change_pct": quote.get("change_pct"),
|
|
})
|
|
return {"count": len(rows), "rows": rows}
|
|
|
|
|
|
def _get_market_overview(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
from app.services.market_overview_builder import build_market_overview
|
|
|
|
repo = _require_repo(ctx)
|
|
return build_market_overview(repo, quote_service=ctx.quote_service, depth_service=ctx.depth_service)
|
|
|
|
|
|
def _get_indices(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
if ctx.quote_service is None:
|
|
raise ValueError("实时行情服务未启用, 无法查询指数。")
|
|
df = ctx.quote_service.get_index_quotes()
|
|
rows = _rows(df, 60)
|
|
if not rows:
|
|
return {"count": 0, "rows": [], "note": "指数实时缓存为空。"}
|
|
return {"count": len(rows), "rows": rows}
|
|
|
|
|
|
def _get_sector_rotation(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
from app.services.sector_rotation import build_sector_rotation
|
|
|
|
repo = _require_repo(ctx)
|
|
kind = str(args.get("kind") or "concept")
|
|
if kind not in {"concept", "industry"}:
|
|
raise ValueError("kind 必须是 concept 或 industry。")
|
|
top = _clamp(args.get("top"), 10, 50, 20)
|
|
data = build_sector_rotation(repo, kind=kind, top=top)
|
|
status = data.get("status")
|
|
if status in {"no_data", "empty"}:
|
|
return {"status": status, "reason": data.get("reason", "")}
|
|
sectors = data.get("sectors")
|
|
cross = data.get("cross_events")
|
|
return {
|
|
"kind": kind,
|
|
"status": status,
|
|
"sectors": sectors[:top] if isinstance(sectors, list) else sectors,
|
|
"cross_events": cross[:20] if isinstance(cross, list) else cross,
|
|
"note": "已省略时间序列矩阵, 只保留板块榜与切换事件。",
|
|
}
|
|
|
|
|
|
def _get_regime(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
from app.services.regime_builder import STATE_LABELS, load_regime_history
|
|
|
|
if ctx.data_dir is None:
|
|
raise ValueError("数据目录未就绪。")
|
|
days = _clamp(args.get("days"), 1, 120, 30)
|
|
df = load_regime_history(ctx.data_dir)
|
|
if df is None or df.is_empty():
|
|
return {"count": 0, "rows": [], "note": "本地暂无市场环境(regime)数据。"}
|
|
keep = [
|
|
c for c in (
|
|
"date", "state", "score", "phase", "max_consecutive",
|
|
"first_board", "ge2_count", "promo_rate", "seal_rate",
|
|
) if c in df.columns
|
|
]
|
|
sub = df.select(keep).sort("date", descending=True).head(days)
|
|
rows = _rows(sub, days)
|
|
for row in rows:
|
|
state = row.get("state")
|
|
if state:
|
|
row["state_label"] = STATE_LABELS.get(str(state), str(state))
|
|
return {"count": len(rows), "rows": rows}
|
|
|
|
|
|
def _get_abnormal(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
from app.services.abnormal_moves import build_intraday, build_overview
|
|
|
|
repo = _require_repo(ctx)
|
|
kind = str(args.get("kind") or "intraday")
|
|
if kind not in {"intraday", "overview"}:
|
|
raise ValueError("kind 必须是 intraday 或 overview。")
|
|
limit = _clamp(args.get("limit"), 10, 200, 50)
|
|
if kind == "intraday":
|
|
data = build_intraday(repo, limit=limit)
|
|
rows = data.get("rows") or []
|
|
return {"kind": kind, "cache_date": data.get("cache_date"), "counts": data.get("counts"), "rows": rows[:limit]}
|
|
data = build_overview(repo, ctx.quote_service, limit=limit)
|
|
rows = data.get("rows") or []
|
|
return {"kind": kind, "rows": rows[:limit]}
|
|
|
|
|
|
def _get_lots(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
from app.strategy.lots import load_all
|
|
|
|
if ctx.data_dir is None:
|
|
raise ValueError("数据目录未就绪。")
|
|
lots = load_all(ctx.data_dir)
|
|
if not lots:
|
|
return {"count": 0, "rows": [], "note": "暂无持仓提醒记录。"}
|
|
symbols = [str(lot.get("symbol") or "") for lot in lots if lot.get("symbol")]
|
|
quotes = _quotes_map(ctx, symbols)
|
|
names = ctx.repo.get_name_map(symbols) if ctx.repo is not None else {}
|
|
rows = []
|
|
for lot in lots[:100]:
|
|
symbol = str(lot.get("symbol") or "")
|
|
quote = quotes.get(symbol, {})
|
|
rows.append({
|
|
"symbol": symbol,
|
|
"name": names.get(symbol, ""),
|
|
"qty": lot.get("qty"),
|
|
"cost_price": lot.get("cost_price"),
|
|
"buy_date": _clean_value(lot.get("buy_date")),
|
|
"target_pct": lot.get("target_pct"),
|
|
"stop_pct": lot.get("stop_pct"),
|
|
"close": quote.get("close"),
|
|
"change_pct": quote.get("change_pct"),
|
|
})
|
|
return {"count": len(rows), "rows": rows}
|
|
|
|
|
|
def _list_signals(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
from app.strategy.custom_signals import load_all
|
|
|
|
if ctx.data_dir is None:
|
|
raise ValueError("数据目录未就绪。")
|
|
rows = [_clean_obj(r) for r in load_all(ctx.data_dir)[:100]]
|
|
return {"count": len(rows), "rows": rows}
|
|
|
|
|
|
def _run_strategy(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
from app.services.screener import ScreenerService
|
|
from app.strategy.config import load_override
|
|
|
|
repo = _require_repo(ctx)
|
|
if ctx.engine is None:
|
|
raise ValueError("策略引擎未就绪。")
|
|
strategy_id = str(args.get("strategy_id") or "").strip()
|
|
if not strategy_id:
|
|
raise ValueError("strategy_id 不能为空, 可先用 list_strategies 查看。")
|
|
if not ctx.engine.has(strategy_id):
|
|
# 与 run_backtest 同口径: 附带可用 id, 模型一次即可自行修正
|
|
try:
|
|
available = ", ".join(
|
|
str(s.get("id")) for s in ctx.engine.list_strategies()[:12] if s.get("id")
|
|
)
|
|
except Exception: # 列举失败不影响错误本身
|
|
available = ""
|
|
hint = f"; 可用策略: {available}" if available else "; 可先用 list_strategies 查询"
|
|
raise ValueError(f"策略 {strategy_id} 不存在{hint}")
|
|
svc = ScreenerService(repo)
|
|
as_of = svc.latest_date()
|
|
if as_of is None:
|
|
raise ValueError("本地暂无行情数据, 无法执行策略。")
|
|
overrides = load_override(ctx.data_dir, strategy_id) or {} if ctx.data_dir else {}
|
|
context = svc.build_strategy_context(
|
|
ctx.engine, as_of, [strategy_id], overrides_map={strategy_id: overrides},
|
|
)
|
|
result = ctx.engine.run(strategy_id, context, overrides=overrides or None)
|
|
rows = [_clean_obj(r) for r in (getattr(result, "rows", None) or [])][:30]
|
|
return {
|
|
"strategy_id": strategy_id,
|
|
"as_of": _clean_value(getattr(result, "as_of", as_of)),
|
|
"total": int(getattr(result, "total", 0) or 0),
|
|
"elapsed_ms": round(float(getattr(result, "elapsed_ms", 0.0) or 0.0)),
|
|
"rows": rows,
|
|
}
|
|
|
|
|
|
def _get_factor_values(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
repo = _require_repo(ctx)
|
|
factor = str(args.get("factor") or "").strip()
|
|
if not factor:
|
|
raise ValueError("factor 不能为空, 可先用 list_factors 查看可用因子 id。")
|
|
top = _clamp(args.get("top"), 5, 50, 30)
|
|
descending = str(args.get("direction") or "desc") != "asc"
|
|
df, as_of = repo.get_enriched_latest()
|
|
if df is None or df.is_empty():
|
|
raise ValueError("本地暂无行情数据, 无法读取因子值。")
|
|
if factor not in df.columns:
|
|
raise ValueError(f"因子 {factor} 不在物化列中, 请先用 list_factors 确认因子 id。")
|
|
keep = [c for c in ("symbol", factor, "change_pct") if c in df.columns]
|
|
sub = (
|
|
df.select(keep)
|
|
.drop_nulls(subset=[factor])
|
|
.sort(factor, descending=descending)
|
|
.head(top)
|
|
)
|
|
names = repo.get_name_map(sub["symbol"].to_list()) if sub.height else {}
|
|
rows = _rows(sub, top)
|
|
for row in rows:
|
|
row.setdefault("name", names.get(row.get("symbol"), ""))
|
|
return {"factor": factor, "as_of": _clean_value(as_of), "count": len(rows), "rows": rows}
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# 动作工具实现 (写操作; 由 chat_service 的确认闸门把守, 未经确认不会到达这里)。
|
|
# ----------------------------------------------------------------
|
|
def _invalidate_custom_signals(data_dir: Path, repo: Any) -> None:
|
|
"""与 api/signals.py 的 _invalidate 同一条失效链, 保证创建后立即可回测。"""
|
|
from app.indicators.pipeline import invalidate_custom_signals
|
|
from app.services import strategy_cache
|
|
from app.strategy import custom_signals
|
|
|
|
custom_signals.invalidate_intraday_cache()
|
|
invalidate_custom_signals()
|
|
strategy_cache.clear_cache(data_dir)
|
|
if repo is not None and hasattr(repo, "clear_cache"):
|
|
repo.clear_cache()
|
|
|
|
|
|
_SIGNAL_FIELD_HINT = (
|
|
"常用字段: close/open/high/low/volume/amount/change_pct/turnover_rate/"
|
|
"ma5/ma10/ma20/ma30/ma60/ema5-ema60/macd_dif/macd_dea/macd_hist/"
|
|
"boll_upper/boll_lower/kdj_k/kdj_d/kdj_j/rsi_6/rsi_14/rsi_24/atr_14/"
|
|
"vol_ma5/vol_ma10/vol_ratio_5d/high_60d/low_60d/momentum_5d-60d/"
|
|
"annual_vol_20d/consecutive_limit_ups; 右值数字直接写, 字段引用写 'field:字段名'"
|
|
)
|
|
|
|
|
|
def _create_signal_strategy(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
from app.strategy import custom_signals
|
|
|
|
if ctx.data_dir is None:
|
|
raise ValueError("数据目录未就绪。")
|
|
name = str(args.get("name") or "").strip()
|
|
kind = str(args.get("kind") or "entry")
|
|
raw_conditions = args.get("conditions")
|
|
if not isinstance(raw_conditions, list) or not raw_conditions:
|
|
raise ValueError("conditions 不能为空, 每条形如 {left, op, right, leftDays?, rightDays?}。")
|
|
conditions: list[dict[str, Any]] = []
|
|
for i, cond in enumerate(raw_conditions[:8]):
|
|
if not isinstance(cond, dict):
|
|
raise ValueError(f"第 {i + 1} 个条件必须是 {{left, op, right}} 对象。")
|
|
right = cond.get("right")
|
|
if isinstance(right, bool) or right is None or str(right).strip() == "":
|
|
raise ValueError(f"第 {i + 1} 个条件: right 不能为空 (数字或 'field:字段名')。")
|
|
try:
|
|
left_days = int(cond.get("leftDays") or 0)
|
|
right_days = int(cond.get("rightDays") or 0)
|
|
except (TypeError, ValueError):
|
|
raise ValueError(f"第 {i + 1} 个条件: leftDays/rightDays 必须是整数。") from None
|
|
conditions.append({
|
|
"left": str(cond.get("left") or "").strip(),
|
|
"op": str(cond.get("op") or ""),
|
|
"right": str(right),
|
|
"leftDays": left_days,
|
|
"rightDays": right_days,
|
|
})
|
|
# id 由助手生成而非模型给定: 不会覆盖用户已有的同名/同 id 信号
|
|
sig = {
|
|
"id": f"csg_a{uuid.uuid4().hex[:10]}",
|
|
"name": name,
|
|
"kind": kind,
|
|
"conditions": conditions,
|
|
"enabled": True,
|
|
"timeframe": "daily",
|
|
"min_bars": 0,
|
|
}
|
|
try:
|
|
custom_signals.validate(sig)
|
|
except ValueError as exc:
|
|
raise ValueError(f"{exc}。{_SIGNAL_FIELD_HINT}") from exc
|
|
custom_signals.save_one(ctx.data_dir, sig)
|
|
_invalidate_custom_signals(ctx.data_dir, ctx.repo)
|
|
return {
|
|
"created": True,
|
|
"signal": {"id": sig["id"], "name": name, "kind": kind, "conditions": conditions},
|
|
"note": "已写入信号库并默认启用, 可在「信号库」页面查看/编辑/删除; 创建成功后可让用户在策略里引用。",
|
|
}
|
|
|
|
|
|
def _add_to_watchlist(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
from app.services import watchlist as watchlist_service
|
|
|
|
symbol = _validate_symbol(args.get("symbol"))
|
|
note = str(args.get("note") or "").strip()[:80]
|
|
current = watchlist_service.list_symbols()
|
|
if symbol in {str(r.get("symbol")) for r in current}:
|
|
return {
|
|
"added": False,
|
|
"symbol": symbol,
|
|
"total": len(current),
|
|
"note": f"{symbol} 已在自选列表中, 未重复添加。",
|
|
}
|
|
rows = watchlist_service.add(symbol, note=note)
|
|
return {
|
|
"added": True,
|
|
"symbol": symbol,
|
|
"total": len(rows),
|
|
"note": f"已把 {symbol} 加入自选列表(当前共 {len(rows)} 只)。",
|
|
}
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
# 数据完整性与补全 — 检查只读, 补全是动作工具(确认闸门把守)。
|
|
# 同步触发复用数据页同一条路径: 盘后管道 / 分钟K扩展 / 财务表调度器,
|
|
# 全部后台执行、单飞去重, 工具立即返回, 进度用 get_sync_status 轮询。
|
|
# ----------------------------------------------------------------
|
|
def _partition_dates(data_dir: Path, subdir: str) -> list[str]:
|
|
"""date=YYYY-MM-DD 分区目录扫描 — 与 /api/data/status 同款轻量口径, 不读 parquet。"""
|
|
root = data_dir / subdir
|
|
if not root.exists():
|
|
return []
|
|
dates = [d.name[5:] for d in root.iterdir() if d.is_dir() and d.name.startswith("date=")]
|
|
return sorted(dates)
|
|
|
|
|
|
def _biz_days_behind(latest_iso: str | None, today: date) -> int:
|
|
"""数据最新日落后今天约多少个工作日 — 无交易日历, 按周一~周五近似, 节假日会高估。
|
|
|
|
数的是最新日之后、截至今天 (含) 的工作日: 周五的数据在周六/周日不算落后。
|
|
"""
|
|
if not latest_iso:
|
|
return 0
|
|
try:
|
|
latest = date.fromisoformat(str(latest_iso)[:10])
|
|
except ValueError:
|
|
return 0
|
|
count = 0
|
|
cur = latest
|
|
while cur < today:
|
|
cur += timedelta(days=1)
|
|
if cur.weekday() < 5:
|
|
count += 1
|
|
return count
|
|
|
|
|
|
def _coverage_section(dates: list[str]) -> dict[str, Any]:
|
|
if not dates:
|
|
return {"present": False}
|
|
return {"present": True, "earliest_date": dates[0], "latest_date": dates[-1], "trading_days": len(dates)}
|
|
|
|
|
|
def _symbol_coverage(
|
|
ctx: ToolContext, symbol: str, global_daily_latest: str | None, today: date,
|
|
) -> dict[str, Any]:
|
|
"""单标的覆盖: 近 30 日日K行数/最新日 + 财务报告期, 用于「为什么这只没数据」。"""
|
|
out: dict[str, Any] = {"symbol": symbol}
|
|
if ctx.repo is not None:
|
|
try:
|
|
asset_type = ctx.repo.resolve_asset_type(symbol)
|
|
df = ctx.repo.get_daily_asset(asset_type, symbol, today - timedelta(days=30), today)
|
|
if df is None or df.is_empty():
|
|
out["daily_missing"] = True
|
|
out["latest_daily"] = None
|
|
else:
|
|
last = df.sort("date").tail(1).to_dicts()[0] if "date" in df.columns else {}
|
|
latest = _clean_value(last.get("date"))
|
|
out["latest_daily"] = latest
|
|
out["daily_rows_30d"] = df.height
|
|
out["daily_missing"] = bool(global_daily_latest and latest and str(latest)[:10] < global_daily_latest)
|
|
except Exception as exc:
|
|
out["daily_error"] = str(exc)[:80]
|
|
if ctx.data_dir is not None:
|
|
try:
|
|
from app.services.financial_sync import get_financial_df
|
|
|
|
df = get_financial_df(ctx.data_dir, "metrics")
|
|
if df is None or df.is_empty() or "symbol" not in df.columns:
|
|
out["has_financials"] = False
|
|
else:
|
|
sub = df.filter(pl.col("symbol") == symbol)
|
|
out["has_financials"] = not sub.is_empty()
|
|
period = next((c for c in ("period_end", "report_date", "end_date") if c in sub.columns), None)
|
|
if period and sub.height:
|
|
out["latest_financial_period"] = _clean_value(sub[period].max())
|
|
except Exception:
|
|
pass
|
|
return out
|
|
|
|
|
|
def _check_data_coverage(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
from app.services.financial_sync import FINANCIAL_TABLES, get_financial_df
|
|
from app.services.pipeline_jobs import job_store
|
|
|
|
if ctx.data_dir is None:
|
|
raise ValueError("数据目录未就绪。")
|
|
today = cn_today()
|
|
daily = _partition_dates(ctx.data_dir, "kline_daily")
|
|
enriched = _partition_dates(ctx.data_dir, "kline_daily_enriched")
|
|
minute = _partition_dates(ctx.data_dir, "kline_minute")
|
|
daily_latest = daily[-1] if daily else None
|
|
enriched_latest = enriched[-1] if enriched else None
|
|
|
|
issues: list[str] = []
|
|
if not daily:
|
|
issues.append("本地没有任何股票日线数据, 请先执行数据同步(盘后管道)。")
|
|
elif _biz_days_behind(daily_latest, today) >= 1:
|
|
behind = _biz_days_behind(daily_latest, today)
|
|
issues.append(f"日线停留在 {daily_latest}, 落后最近工作日约 {behind} 天(节假日可能高估), 建议补全。")
|
|
if daily and not enriched:
|
|
issues.append("指标数据(enriched)尚未生成, 多数分析页与策略依赖它, 建议运行盘后管道。")
|
|
elif daily and enriched and enriched_latest < daily_latest:
|
|
issues.append(f"指标(enriched)停留在 {enriched_latest}, 落后日线 {daily_latest}, 建议重跑盘后管道。")
|
|
if not minute:
|
|
issues.append("本地无分钟K数据(分时图/盘中信号依赖; 未开启分钟同步可忽略)。")
|
|
|
|
financials: dict[str, Any] = {}
|
|
any_financial = False
|
|
for table in FINANCIAL_TABLES:
|
|
try:
|
|
df = get_financial_df(ctx.data_dir, table)
|
|
except Exception:
|
|
df = None
|
|
if df is None or df.is_empty():
|
|
financials[table] = {"present": False}
|
|
continue
|
|
any_financial = True
|
|
period = next((c for c in ("period_end", "report_date", "end_date") if c in df.columns), None)
|
|
financials[table] = {
|
|
"present": True,
|
|
"rows": df.height,
|
|
"latest_period": _clean_value(df[period].max()) if period else None,
|
|
}
|
|
if not any_financial:
|
|
issues.append("本地没有任何财务数据, 财务问答需先同步财务表。")
|
|
|
|
result: dict[str, Any] = {
|
|
"today": today.isoformat(),
|
|
"daily": _coverage_section(daily),
|
|
"enriched": _coverage_section(enriched),
|
|
"minute": _coverage_section(minute),
|
|
"financials": financials,
|
|
"issues": issues,
|
|
}
|
|
|
|
try:
|
|
job_store.reap_stale()
|
|
active_id = job_store.active_id()
|
|
except Exception:
|
|
active_id = None
|
|
if active_id:
|
|
job = job_store.get(active_id) or {}
|
|
result["active_job"] = {
|
|
"job_id": active_id,
|
|
"stage": job.get("stage"),
|
|
"progress": job.get("progress"),
|
|
"status": job.get("status"),
|
|
}
|
|
|
|
symbol = str(args.get("symbol") or "").strip()
|
|
if symbol:
|
|
symbol = _validate_symbol(symbol)
|
|
info = _symbol_coverage(ctx, symbol, daily_latest, today)
|
|
result["symbol"] = info
|
|
if info.get("daily_missing"):
|
|
issues.append(
|
|
f"{symbol} 本地日线缺失或停更(最新 {info.get('latest_daily') or '无数据'}), "
|
|
"可先补全数据再分析。"
|
|
)
|
|
return result
|
|
|
|
|
|
# ── 扩展数据表只读问答: 复用数据页 ext_data 的同一条读取链 ──────────
|
|
|
|
def _ext_table_freshness(data_dir: Path, table_id: str, mode: str) -> dict[str, Any]:
|
|
"""扫描分区目录给出数据新鲜度(轻量, 不读 parquet 内容)。
|
|
|
|
timeseries: 最新分区日期 + 分区数; snapshot: part.parquet 是否存在。
|
|
"""
|
|
if mode == "snapshot":
|
|
exists = (data_dir / "ext_data" / table_id / "part.parquet").exists()
|
|
return {"has_data": exists}
|
|
base = data_dir / "ext_data" / table_id / "timeseries"
|
|
if not base.exists():
|
|
return {"has_data": False}
|
|
dates = sorted(
|
|
d.name[5:] for d in base.iterdir()
|
|
if d.is_dir() and d.name.startswith("date=") and (d / "part.parquet").exists()
|
|
)
|
|
if not dates:
|
|
return {"has_data": False}
|
|
return {"has_data": True, "latest_date": dates[-1], "earliest_date": dates[0], "days": len(dates)}
|
|
|
|
|
|
def _list_ext_tables(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
"""列出用户配置的扩展数据表: 模式、字段、说明与数据日期。"""
|
|
if ctx.data_dir is None:
|
|
raise ValueError("数据目录不可用")
|
|
from app.services.ext_data import ExtConfigStore
|
|
|
|
configs = ExtConfigStore(Path(ctx.data_dir)).load_all()
|
|
tables: list[dict[str, Any]] = []
|
|
for c in configs:
|
|
item: dict[str, Any] = {
|
|
"id": c.id,
|
|
"label": c.label,
|
|
"mode": c.mode,
|
|
"market_level": c.market_level,
|
|
"description": c.description,
|
|
"fields": [{"name": f.name, "dtype": f.dtype, "label": f.label} for f in c.fields],
|
|
}
|
|
item.update(_ext_table_freshness(Path(ctx.data_dir), c.id, c.mode))
|
|
tables.append(item)
|
|
tables.sort(key=lambda t: (not t.get("has_data"), t["id"]))
|
|
result: dict[str, Any] = {"tables": tables, "count": len(tables)}
|
|
if not tables:
|
|
result["note"] = "还没有配置任何扩展数据表; 可在数据页的扩展数据里创建或上传。"
|
|
return result
|
|
|
|
|
|
def _query_ext_table(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
"""读取一张扩展数据表的行: 支持等值过滤、排序、日期范围, limit 封顶 100。"""
|
|
if ctx.data_dir is None:
|
|
raise ValueError("数据目录不可用")
|
|
from fastapi import HTTPException
|
|
|
|
from app.api.ext_data import _apply_row_filters, _apply_row_sort, _read_ext_dataframe
|
|
from app.services.ext_data import ExtConfigStore
|
|
|
|
table = str(args.get("table") or "").strip()
|
|
if not table:
|
|
raise ValueError("缺少参数 table (扩展表 id, 可先用 list_ext_tables 查询)")
|
|
config = ExtConfigStore(Path(ctx.data_dir)).get(table)
|
|
if config is None:
|
|
raise ValueError(f"扩展表不存在: {table} (可先用 list_ext_tables 查看全部表)")
|
|
|
|
try:
|
|
df, active_date = _read_ext_dataframe(
|
|
config,
|
|
Path(ctx.data_dir),
|
|
str(args["date"]) if args.get("date") else None,
|
|
str(args["start_date"]) if args.get("start_date") else None,
|
|
str(args["end_date"]) if args.get("end_date") else None,
|
|
)
|
|
if args.get("filter"):
|
|
df = _apply_row_filters(df, [str(args["filter"])])
|
|
if args.get("sort"):
|
|
df = _apply_row_sort(df, str(args["sort"]))
|
|
except HTTPException as exc: # 复用的过滤/排序/日期校验按契约转错误文案
|
|
raise ValueError(str(exc.detail)) from exc
|
|
|
|
total = df.height
|
|
limit = max(1, min(100, int(args.get("limit") or 20)))
|
|
rows = [
|
|
{k: _clean_value(v) for k, v in row.items()}
|
|
for row in df.head(limit).to_dicts()
|
|
]
|
|
result: dict[str, Any] = {
|
|
"id": config.id,
|
|
"label": config.label,
|
|
"mode": config.mode,
|
|
"date": active_date,
|
|
"total": total,
|
|
"returned": len(rows),
|
|
"limit": limit,
|
|
"columns": df.columns,
|
|
"rows": rows,
|
|
}
|
|
if total > len(rows):
|
|
result["note"] = (
|
|
f"共 {total} 行, 只返回前 {len(rows)} 行; 可用 filter=字段:值1|值2 过滤、"
|
|
"sort=字段:desc 排序后再查。"
|
|
)
|
|
return result
|
|
|
|
|
|
def _get_sync_status(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
import contextlib
|
|
|
|
from app.services.pipeline_jobs import job_store
|
|
|
|
keys = ("id", "status", "stage", "progress", "error", "started_at", "finished_at", "duration_s")
|
|
with contextlib.suppress(Exception):
|
|
job_store.reap_stale()
|
|
out: dict[str, Any] = {"recent_jobs": []}
|
|
active_id = None
|
|
with contextlib.suppress(Exception):
|
|
active_id = job_store.active_id()
|
|
if active_id:
|
|
job = job_store.get(active_id) or {}
|
|
out["active_job"] = {k: _clean_value(job.get(k)) for k in keys if job.get(k) is not None}
|
|
with contextlib.suppress(Exception):
|
|
out["recent_jobs"] = [
|
|
{k: _clean_value(j.get(k)) for k in keys if j.get(k) is not None}
|
|
for j in job_store.list_recent(limit=5)
|
|
]
|
|
fs = ctx.financial_scheduler
|
|
if fs is not None:
|
|
out["financial_syncing"] = bool(getattr(fs, "is_syncing", False))
|
|
out["financial_last_sync"] = _clean_obj(getattr(fs, "last_sync", None) or {})
|
|
if not active_id and not out.get("financial_syncing"):
|
|
out["note"] = "当前没有正在运行的数据任务。"
|
|
return out
|
|
|
|
|
|
async def _sync_data(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
"""数据补全(动作工具): 触发后台同步任务并立即返回, 不等待完成。
|
|
|
|
kind=pipeline 复用 POST /api/pipeline/run 的触发路径(单飞),
|
|
kind=minute_extend 复用 POST /api/kline/sync_minute 的向前扩展模式,
|
|
kind=financials 复用 POST /api/financials/sync/{table} 的调度器。
|
|
"""
|
|
from fastapi import HTTPException
|
|
|
|
kind = str(args.get("kind") or "pipeline")
|
|
if kind not in {"pipeline", "financials", "minute_extend"}:
|
|
raise ValueError("kind 必须是 pipeline(盘后管道) / financials(财务表) / minute_extend(扩展分钟K历史) 之一。")
|
|
if ctx.data_dir is None:
|
|
raise ValueError("数据目录未就绪。")
|
|
|
|
if kind == "pipeline":
|
|
if ctx.repo is None or ctx.capabilities is None:
|
|
raise ValueError("数据仓库或数据源能力未就绪, 无法启动同步。")
|
|
from app.api.pipeline import trigger_pipeline_job
|
|
|
|
trigger = await trigger_pipeline_job(ctx.repo, ctx.capabilities, ctx.quote_service)
|
|
return {
|
|
"started": True,
|
|
"kind": kind,
|
|
"reused": bool(trigger.get("reused")),
|
|
"job_id": trigger.get("job_id"),
|
|
"note": "盘后管道已在后台执行(日K/指标/复权因子/名单, 含已启用的分钟K), "
|
|
"可用 get_sync_status 查进度; 完成前部分数据会停留在旧交易日。",
|
|
}
|
|
|
|
if kind == "minute_extend":
|
|
if ctx.repo is None or ctx.capabilities is None:
|
|
raise ValueError("数据仓库或数据源能力未就绪, 无法启动同步。")
|
|
from app.api.kline import trigger_minute_sync
|
|
|
|
days = _clamp(args.get("days"), 30, 1095, 365)
|
|
try:
|
|
trigger = await trigger_minute_sync(
|
|
ctx.repo, ctx.capabilities, override_days=days, extend_flag=True,
|
|
)
|
|
except HTTPException as exc: # 权限不足等 4xx → 工具错误契约
|
|
raise ValueError(str(getattr(exc, "detail", "") or exc)) from exc
|
|
return {
|
|
"started": True,
|
|
"kind": kind,
|
|
"days": days,
|
|
"reused": trigger.get("status") == "reused",
|
|
"job_id": trigger.get("job_id"),
|
|
"note": f"分钟K向前扩展已启动(目标 {days} 天), 全市场拉取耗时较长, 可用 get_sync_status 轮询进度。",
|
|
}
|
|
|
|
fs = ctx.financial_scheduler
|
|
if fs is None:
|
|
raise ValueError("财务同步调度器未就绪(需要数据源的财务数据能力)。")
|
|
from app.services.financial_sync import FINANCIAL_TABLES
|
|
|
|
table = str(args.get("table") or "all")
|
|
if table not in {*FINANCIAL_TABLES, "all"}:
|
|
raise ValueError(f"table 必须是 {sorted(FINANCIAL_TABLES)} 之一或 all。")
|
|
target = None if table == "all" else table
|
|
detail = await asyncio.to_thread(fs.trigger, target)
|
|
return {
|
|
"started": True,
|
|
"kind": kind,
|
|
"table": table,
|
|
"detail": _clean_obj(detail),
|
|
"note": "财务表同步已在后台执行(全量需数分钟), 可用 get_sync_status 查看进度。",
|
|
}
|
|
|
|
|
|
_LOCAL_TOOLS: dict[str, Callable[[dict[str, Any], ToolContext], Any]] = {
|
|
"get_stock_quote": _get_stock_quote,
|
|
"get_stock_daily": _get_stock_daily,
|
|
"get_stock_analysis": _get_stock_analysis,
|
|
"get_financials": _get_financials,
|
|
"get_watchlist": _get_watchlist,
|
|
"get_market_overview": _get_market_overview,
|
|
"get_indices": _get_indices,
|
|
"get_sector_rotation": _get_sector_rotation,
|
|
"get_regime": _get_regime,
|
|
"get_abnormal": _get_abnormal,
|
|
"get_lots": _get_lots,
|
|
"list_signals": _list_signals,
|
|
"run_strategy": _run_strategy,
|
|
"get_factor_values": _get_factor_values,
|
|
"create_signal_strategy": _create_signal_strategy,
|
|
"add_to_watchlist": _add_to_watchlist,
|
|
"check_data_coverage": _check_data_coverage,
|
|
"list_ext_tables": _list_ext_tables,
|
|
"query_ext_table": _query_ext_table,
|
|
"get_sync_status": _get_sync_status,
|
|
"sync_data": _sync_data,
|
|
}
|
|
|
|
|
|
def _schema(name: str, description: str, properties: dict[str, Any], required: list[str]) -> dict[str, Any]:
|
|
return {
|
|
"type": "function",
|
|
"function": {
|
|
"name": name,
|
|
"description": description,
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": properties,
|
|
"required": required,
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _local_tool_schemas() -> list[dict[str, Any]]:
|
|
return [
|
|
_schema(
|
|
"get_stock_quote",
|
|
"查询一只或多只股票(≤50)的实时行情快照: 最新价、涨跌幅、成交额、换手率、振幅等。",
|
|
{
|
|
"symbols": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"description": "证券代码数组, 如 ['600519.SH', '300750.SZ']",
|
|
},
|
|
},
|
|
["symbols"],
|
|
),
|
|
_schema(
|
|
"get_stock_daily",
|
|
"查询个股日线行情(近 N 个交易日, ≤500): OHLCV、涨跌幅、换手率与常用均线。",
|
|
{
|
|
"symbol": {"type": "string", "description": "证券代码, 如 600519.SH"},
|
|
"days": {"type": "integer", "description": "返回最近 N 个交易日 (10-500, 默认 60)"},
|
|
},
|
|
["symbol"],
|
|
),
|
|
_schema(
|
|
"get_stock_analysis",
|
|
"个股分析: 基于 120 日日线计算 11 类关键价位(支撑/压力/枢轴/布林等)与文字摘要, 附最新行情快照。",
|
|
{"symbol": {"type": "string", "description": "证券代码"}},
|
|
["symbol"],
|
|
),
|
|
_schema(
|
|
"get_financials",
|
|
"查询个股财务报表数据(本地已同步的最近几个报告期)。table: metrics(核心指标)/income(利润)/balance_sheet(资产负债)/cash_flow(现金流)/shares(股本)。",
|
|
{
|
|
"symbol": {"type": "string", "description": "证券代码"},
|
|
"table": {"type": "string", "enum": ["metrics", "income", "balance_sheet", "cash_flow", "shares"], "description": "报表类型, 默认 metrics"},
|
|
"periods": {"type": "integer", "description": "最近 N 个报告期 (1-8, 默认 4)"},
|
|
},
|
|
["symbol"],
|
|
),
|
|
_schema(
|
|
"get_watchlist",
|
|
"查询用户的自选列表(含备注), 并合并最新价与今日涨跌幅, 用于回答「我的自选表现如何」。",
|
|
{},
|
|
[],
|
|
),
|
|
_schema(
|
|
"get_market_overview",
|
|
"市场总览(看板首页口径): 指数、涨跌家数、成交额、涨停/连板梯队、涨跌幅分布、均线趋势占比、情绪雷达与领涨领跌/活跃榜。无参数。",
|
|
{},
|
|
[],
|
|
),
|
|
_schema(
|
|
"get_indices",
|
|
"查询指数实时行情表(上证/深证/创业板/科创50 等), 返回点位与涨跌幅。",
|
|
{},
|
|
[],
|
|
),
|
|
_schema(
|
|
"get_sector_rotation",
|
|
"盘中板块轮动(概念/行业分析页口径): 活跃板块榜、板块切换事件与资金流向排名。",
|
|
{
|
|
"kind": {"type": "string", "enum": ["concept", "industry"], "description": "板块维度, 默认 concept"},
|
|
"top": {"type": "integer", "description": "榜单数量 (10-50, 默认 20)"},
|
|
},
|
|
[],
|
|
),
|
|
_schema(
|
|
"get_regime",
|
|
"市场环境(regime)时序: 最近 N 日的强弱状态、情绪分、连板高度、晋级率/封板率与情绪周期阶段。",
|
|
{"days": {"type": "integer", "description": "最近 N 个交易日 (1-120, 默认 30)"}},
|
|
[],
|
|
),
|
|
_schema(
|
|
"get_abnormal",
|
|
"异动监控: intraday=盘中异动(涨停/炸板/跌停/新高新低/放量), overview=交易所异动规则偏离总览。",
|
|
{
|
|
"kind": {"type": "string", "enum": ["intraday", "overview"], "description": "默认 intraday"},
|
|
"limit": {"type": "integer", "description": "返回行数上限 (10-200, 默认 50)"},
|
|
},
|
|
[],
|
|
),
|
|
_schema(
|
|
"get_lots",
|
|
"查询用户的持仓提醒(lots): 持仓数量、成本、目标/止损幅度, 合并最新价与今日涨跌幅。",
|
|
{},
|
|
[],
|
|
),
|
|
_schema(
|
|
"list_signals",
|
|
"检索用户自定义信号的目录(信号库页): 名称、表达式、分组与启用状态。",
|
|
{},
|
|
[],
|
|
),
|
|
_schema(
|
|
"run_strategy",
|
|
"以最新交易日报酬面执行一个策略并返回命中标的列表(≤30 行)。strategy_id 可先用 list_strategies 查询。",
|
|
{"strategy_id": {"type": "string", "description": "策略 id"}},
|
|
["strategy_id"],
|
|
),
|
|
_schema(
|
|
"get_factor_values",
|
|
"查询某因子在最新交易日的全市场取值排名(Top N), 附股票名称与今日涨跌幅。factor 用因子 id, 可先用 list_factors 查询。",
|
|
{
|
|
"factor": {"type": "string", "description": "因子 id"},
|
|
"top": {"type": "integer", "description": "返回前 N 名 (5-50, 默认 30)"},
|
|
"direction": {"type": "string", "enum": ["desc", "asc"], "description": "排序方向, 默认 desc(从大到小)"},
|
|
},
|
|
["factor"],
|
|
),
|
|
_schema(
|
|
"create_signal_strategy",
|
|
"在信号库创建一个自定义信号(动作工具, 须用户在确认卡上确认后才执行)。"
|
|
"把交易思路翻译成声明式条件: 白名单字段 + 比较运算符, 多条件之间为 AND。"
|
|
"字段如 close/open/high/low/volume/change_pct/turnover_rate/ma5/ma10/ma20/ma60/"
|
|
"macd_dif/macd_dea/macd_hist/boll_upper/boll_lower/kdj_k/kdj_d/kdj_j/rsi_6/rsi_14/"
|
|
"vol_ratio_5d/high_60d/low_60d/momentum_5d 等; 上穿/下穿用天数偏移表达"
|
|
"(如 close>field:ma20 且 1 日前 close<=field:ma20 即金叉)。",
|
|
{
|
|
"name": {"type": "string", "description": "信号名称, 简洁中文 (如 放量突破20日线)"},
|
|
"kind": {"type": "string", "enum": ["entry", "exit", "both"], "description": "entry=入场信号, exit=出场信号, both=两者"},
|
|
"conditions": {
|
|
"type": "array",
|
|
"maxItems": 8,
|
|
"description": "条件列表 (1-8 条, 之间 AND)",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"left": {"type": "string", "description": "左字段名 (白名单内)"},
|
|
"op": {"type": "string", "enum": [">", ">=", "<", "<=", "==", "!="], "description": "比较运算符"},
|
|
"right": {"type": "string", "description": "数字 (如 1.5) 或字段引用 'field:字段名'"},
|
|
"leftDays": {"type": "integer", "description": "左字段取 N 个交易日前 (0-60, 默认 0)"},
|
|
"rightDays": {"type": "integer", "description": "右字段取 N 个交易日前 (仅 right 为字段时有效, 默认 0)"},
|
|
},
|
|
"required": ["left", "op", "right"],
|
|
},
|
|
},
|
|
},
|
|
["name", "kind", "conditions"],
|
|
),
|
|
_schema(
|
|
"add_to_watchlist",
|
|
"把一只标的加入用户自选列表(动作工具, 须用户在确认卡上确认后才执行)。",
|
|
{
|
|
"symbol": {"type": "string", "description": "证券代码, 如 600519.SH"},
|
|
"note": {"type": "string", "description": "备注 (可选, ≤80 字)"},
|
|
},
|
|
["symbol"],
|
|
),
|
|
_schema(
|
|
"check_data_coverage",
|
|
"检查本地数据完整性: 日线/指标(enriched)/分钟K的覆盖区间与新旧、财务表覆盖、"
|
|
"正在运行的同步任务, 返回问题清单 issues(为空即数据完整)。"
|
|
"发现『数据停留在旧交易日』『指标落后日线』『无财务数据』等缺口时, 建议用户补全。",
|
|
{"symbol": {"type": "string", "description": "可选: 顺带检查单只标的的日线/财务覆盖"}},
|
|
[],
|
|
),
|
|
_schema(
|
|
"list_ext_tables",
|
|
"列出用户配置的扩展数据表(二开扩展内容): 每张表的模式(快照/时序)、字段、说明"
|
|
"与最新数据日期。用户问到扩展表/自定义数据/上传的数据时先调它。",
|
|
{},
|
|
[],
|
|
),
|
|
_schema(
|
|
"query_ext_table",
|
|
"读取一张扩展数据表的行数据。table 为表 id(先用 list_ext_tables 查); "
|
|
"filter=字段:值1|值2 等值过滤(值间 OR); sort=字段 或 字段:desc 排序; "
|
|
"时序表支持 start_date/end_date(YYYY-MM-DD)或 date 单日。limit 默认 20, 上限 100。",
|
|
{
|
|
"table": {"type": "string", "description": "扩展表 id"},
|
|
"filter": {"type": "string", "description": "等值过滤: 字段:值1|值2"},
|
|
"sort": {"type": "string", "description": "排序字段, 降序加 :desc"},
|
|
"date": {"type": "string", "description": "时序表单日 YYYY-MM-DD"},
|
|
"start_date": {"type": "string", "description": "时序表起始日期(含)"},
|
|
"end_date": {"type": "string", "description": "时序表结束日期(含)"},
|
|
"limit": {"type": "integer", "description": "返回行数上限 (默认 20, 最大 100)"},
|
|
},
|
|
["table"],
|
|
),
|
|
_schema(
|
|
"get_sync_status",
|
|
"查询数据同步任务进度: 活跃任务的阶段/百分比/消息、最近 5 个任务、财务同步状态。"
|
|
"sync_data 触发后用它轮询(建议间隔数秒, 不要高频轮询)。",
|
|
{},
|
|
[],
|
|
),
|
|
_schema(
|
|
"sync_data",
|
|
"数据补全(动作工具, 须用户在确认卡上确认后才执行; 后台执行, 立即返回)。"
|
|
"kind=pipeline 盘后管道(日K/指标/复权/名单, 含已启用的分钟K, 补最新交易日); "
|
|
"kind=financials 财务表(table 可选 metrics/income/balance_sheet/cash_flow/shares/all); "
|
|
"kind=minute_extend 扩展分钟K历史(days 30-1095, 默认 365, 用于回测深度)。",
|
|
{
|
|
"kind": {"type": "string", "enum": ["pipeline", "financials", "minute_extend"], "description": "补全类型"},
|
|
"table": {"type": "string", "enum": ["metrics", "income", "balance_sheet", "cash_flow", "shares", "all"], "description": "仅 kind=financials: 同步哪张表, 默认 all"},
|
|
"days": {"type": "integer", "description": "仅 kind=minute_extend: 目标天数 (30-1095, 默认 365)"},
|
|
},
|
|
["kind"],
|
|
),
|
|
]
|
|
|
|
|
|
def build_tool_schemas() -> list[dict[str, Any]]:
|
|
"""核心工具目录(因子/策略/能力/回测) + 本模块的页面查询工具。"""
|
|
return [*tool_catalog.build_tool_schemas(), *_local_tool_schemas()]
|
|
|
|
|
|
async def execute_assistant_tool(name: str, args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
"""统一执行入口: 本地工具(to_thread 防阻塞) → 核心工具目录回退。
|
|
|
|
契约与核心 tool_catalog.execute_tool 一致: {"ok": bool, "result"|"error"}。
|
|
"""
|
|
handler = _LOCAL_TOOLS.get(name)
|
|
if handler is not None:
|
|
try:
|
|
if asyncio.iscoroutinefunction(handler):
|
|
# async 工具(sync_data)需要在事件循环上调度后台任务, 不挪线程
|
|
result = await handler(dict(args or {}), ctx)
|
|
else:
|
|
result = await asyncio.to_thread(handler, dict(args or {}), ctx)
|
|
return {"ok": True, "result": result}
|
|
except Exception as exc: # 工具失败按契约回填给模型, 不打断对话流
|
|
return {"ok": False, "error": str(exc) or exc.__class__.__name__}
|
|
return await tool_catalog.execute_tool(
|
|
name,
|
|
args,
|
|
engine=ctx.engine,
|
|
data_dir=str(ctx.data_dir) if ctx.data_dir else None,
|
|
)
|
|
|
|
|
|
def summarize_tool_result(name: str, payload: dict[str, Any]) -> str:
|
|
"""把工具返回压缩成一行人类可读摘要, 用于前端足迹卡。"""
|
|
if not payload.get("ok"):
|
|
error = str(payload.get("error") or "执行失败")
|
|
return f"失败: {error[:80]}"
|
|
|
|
result = payload.get("result")
|
|
if not isinstance(result, dict):
|
|
return "完成"
|
|
|
|
def _count(key: str = "rows") -> int:
|
|
rows = result.get(key)
|
|
return len(rows) if isinstance(rows, list) else int(result.get("count") or 0)
|
|
|
|
if name == tool_catalog.LIST_FACTORS:
|
|
factors = result.get("factors")
|
|
return f"返回 {len(factors) if isinstance(factors, list) else 0} 个因子"
|
|
if name == tool_catalog.LIST_STRATEGIES:
|
|
strategies = result.get("strategies")
|
|
return f"返回 {len(strategies) if isinstance(strategies, list) else 0} 个策略"
|
|
if name == tool_catalog.LIST_DATA_CAPABILITIES:
|
|
caps = result.get("capabilities")
|
|
return f"返回 {len(caps) if isinstance(caps, list) else 0} 项能力"
|
|
if name == tool_catalog.RUN_BACKTEST:
|
|
stats = result.get("stats")
|
|
if not isinstance(stats, dict) or not stats:
|
|
return "回测完成(无指标)"
|
|
return " · ".join(str(p) for p in (
|
|
f"总收益 {stats.get('total_return', '-')}",
|
|
f"年化 {stats.get('annual_return', '-')}",
|
|
f"回撤 {stats.get('max_drawdown', '-')}",
|
|
f"夏普 {stats.get('sharpe', '-')}",
|
|
))
|
|
if name == "get_stock_quote":
|
|
return f"查询 {_count()} 只个股实时行情"
|
|
if name == "get_stock_daily":
|
|
return f"返回 {_count()} 个交易日日线"
|
|
if name == "get_stock_analysis":
|
|
return "完成个股关键价位分析"
|
|
if name == "get_financials":
|
|
return f"返回 {result.get('table', '财务')} 近 {_count()} 期"
|
|
if name == "get_watchlist":
|
|
return f"返回自选 {_count()} 只"
|
|
if name == "get_market_overview":
|
|
breadth = result.get("breadth") or {}
|
|
limit = (result.get("limit") or {}).get("limit_up", "-")
|
|
return f"总览: 涨 {breadth.get('up', '-')} / 跌 {breadth.get('down', '-')} · 涨停 {limit}"
|
|
if name == "get_indices":
|
|
return f"返回 {_count()} 条指数行情"
|
|
if name == "get_sector_rotation":
|
|
return "完成板块轮动查询"
|
|
if name == "get_regime":
|
|
rows = result.get("rows") or []
|
|
latest = rows[0].get("state_label") if rows else "-"
|
|
return f"返回 {_count()} 日 regime, 最新 {latest}"
|
|
if name == "get_abnormal":
|
|
return f"返回异动 {_count()} 行"
|
|
if name == "get_lots":
|
|
return f"返回持仓提醒 {_count()} 条"
|
|
if name == "list_signals":
|
|
return f"返回 {_count()} 个信号定义"
|
|
if name == "run_strategy":
|
|
return f"命中 {result.get('total', 0)} 只 · 耗时 {result.get('elapsed_ms', '-')}ms"
|
|
if name == "get_factor_values":
|
|
return f"返回因子 {result.get('factor', '-')} Top{_count()}"
|
|
if name == "create_signal_strategy":
|
|
signal = result.get("signal") or {}
|
|
return f"创建信号 {signal.get('name', '-')} ({signal.get('id', '-')})"
|
|
if name == "add_to_watchlist":
|
|
return result.get("note") or "已加入自选"
|
|
if name == "check_data_coverage":
|
|
issue_count = len(result.get("issues") or [])
|
|
return f"发现 {issue_count} 项数据缺口" if issue_count else "数据完整, 无缺口"
|
|
if name == "list_ext_tables":
|
|
count = result.get("count", 0)
|
|
return f"返回 {count} 张扩展表" if count else "暂无扩展数据表"
|
|
if name == "query_ext_table":
|
|
return f"读取扩展表 {result.get('label', '-')} {result.get('returned', 0)}/{result.get('total', 0)} 行"
|
|
if name == "get_sync_status":
|
|
active = result.get("active_job")
|
|
if active:
|
|
return f"进行中: {active.get('stage', '-')} {active.get('progress', 0)}%"
|
|
if result.get("financial_syncing"):
|
|
return "财务表同步中"
|
|
return "当前无同步任务"
|
|
if name == "sync_data":
|
|
job_id = result.get("job_id")
|
|
reused = result.get("reused")
|
|
label = {"pipeline": "盘后管道", "financials": "财务表同步", "minute_extend": "分钟K扩展"}.get(
|
|
result.get("kind"), "同步")
|
|
if reused:
|
|
return f"复用进行中的{label}任务 {job_id or '-'}"
|
|
return f"已启动{label}任务 {job_id or '-'}"
|
|
|
|
return f"返回 {_count()} 行" if _count() else "完成"
|