From e28cc94537a10cfc750dc04bf0d007ae480ae738 Mon Sep 17 00:00:00 2001 From: Weaxs <459312872@qq.com> Date: Thu, 2 Jul 2026 00:39:33 +0800 Subject: [PATCH 1/6] feat: agentic context tools, host integration parity, three-layer e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 8 new tools focused on agent decision-making context (not raw data fetching), completes Hermes and OpenClaw integration for the new tools, strengthens data-source diagnostics, and introduces three layers of end-to-end tests plus CI wiring for them. ## New tools (agent context, not data) - diagnose_data_sources: probe which providers work per market - get_market_capabilities: return supported/unsupported tools per market - render_stock_report / render_market_report: j2 templates → markdown - build_watchlist_context: score/trend/anomalies + next_tools per symbol - analyze_position_context: stateless pnl/risk given cost + quantity - check_alert_rules: stateless rule evaluation - parse_stock_list: extract symbols from freeform text - backtest run: extended diagnostics field (trade_count_quality, main_failure_reason, regime_fit, suggested_parameter_changes) ## Host integration - pi/index.ts, hermes/{tools,schemas,__init__,plugin.yaml}, openclaw/{index.ts,openclaw.plugin.json} all register the same 39 tools - All hosts round-trip identically; integration tests assert three-way parity - typings/openclaw: shim Array/Boolean for Type surface ## Diagnostics correctness (P2) - Finnhub now probes the finnhub package (not requests) - Longbridge probes longport.openapi - finnhub-python added to tools/requirements.txt ## Distribution (P1c) - pyproject: jinja2 as hard dep; force-include templates/ and schemas/ - package.json.files: include templates/ - tools/requirements.txt: add jinja2, finnhub-python ## Three-layer e2e - Layer 1 (host↔python subprocess): 4 non-network tools × 3 hosts - tests/e2e/test_host_to_python.py (pytest, covers Hermes handlers) - tests/e2e/test_pi_e2e.ts, test_openclaw_e2e.ts (real subprocess) - Layer 2 (real provider network): akshare + yfinance + calendar - tests/e2e/test_provider_network.py, @pytest.mark.integration_network - Layer 3 (LLM tool_use): DeepSeek → tool_calls → handler → answer - tests/e2e/test_hermes_llm.py, test_pi_llm.ts - @pytest.mark.integration_llm; opt-in via DEEPSEEK_API_KEY - Model overridable via DEEPSEEK_MODEL (default deepseek-v4-flash) ## Pytest markers + defaults - pyproject: register integration_network and integration_llm markers - addopts excludes both by default — unit tests never touch network/LLM ## CI (.github/workflows/ci.yml) - integration job runs Layer 1 e2e (3 hosts) after registration checks - e2e-network job: Layer 2, always on push/PR - e2e-llm job: Layer 3, skipped on forks, needs DEEPSEEK_API_KEY secret ## Unit tests - 8 new test files for the 8 new tools (~75 tests) - Layer 1 e2e: 8 more (pytest) + 8 (tsx) - Layer 2 e2e: 4 (marked) - Layer 3 e2e: 2 pytest + 2 tsx (marked) - Total: 423 passed, 6 deselected by default markers (1.8s) ## Notes - integration_network hits real providers and may fail loud on upstream outages — that's intentional; skip is worse than red - integration_llm consumes API tokens; only runs where secret is set --- .github/workflows/ci.yml | 76 +++++ hermes/__init__.py | 8 + hermes/plugin.yaml | 8 + hermes/schemas.py | 110 ++++++ hermes/tools.py | 61 ++++ openclaw/index.ts | 197 +++++++++++ openclaw/openclaw.plugin.json | 10 +- package-lock.json | 477 ++++++++++++++++++++++++++- package.json | 2 + pi/index.ts | 194 +++++++++++ pyproject.toml | 8 + requirements-dev.txt | 1 + schemas/market_review_schema.json | 176 ++++++++++ templates/_macros.j2 | 90 +++++ templates/market_review/full.md.j2 | 103 ++++++ templates/stock_analysis/brief.md.j2 | 28 ++ templates/stock_analysis/full.md.j2 | 108 ++++++ tests/e2e/test_hermes_llm.py | 167 ++++++++++ tests/e2e/test_host_to_python.py | 143 ++++++++ tests/e2e/test_openclaw_e2e.ts | 130 ++++++++ tests/e2e/test_pi_e2e.ts | 132 ++++++++ tests/e2e/test_pi_llm.ts | 175 ++++++++++ tests/e2e/test_provider_network.py | 74 +++++ tests/test_alert_rules.py | 109 ++++++ tests/test_backtest_advanced.py | 108 ++++++ tests/test_capabilities.py | 45 +++ tests/test_diagnostics.py | 112 +++++++ tests/test_hermes_integration.py | 8 + tests/test_import_parser.py | 93 ++++++ tests/test_openclaw_integration.ts | 32 ++ tests/test_pi_integration.ts | 8 + tests/test_position_context.py | 69 ++++ tests/test_report_renderer.py | 107 ++++++ tests/test_watchlist_context.py | 77 +++++ tools/alert_rules.py | 228 +++++++++++++ tools/backtest.py | 113 +++++++ tools/capabilities.py | 101 ++++++ tools/diagnostics.py | 131 ++++++++ tools/import_parser.py | 259 +++++++++++++++ tools/position_context.py | 156 +++++++++ tools/report_renderer.py | 111 +++++++ tools/requirements.txt | 2 + tools/watchlist_context.py | 180 ++++++++++ typings/openclaw/index.d.ts | 2 + 44 files changed, 4526 insertions(+), 3 deletions(-) create mode 100644 schemas/market_review_schema.json create mode 100644 templates/_macros.j2 create mode 100644 templates/market_review/full.md.j2 create mode 100644 templates/stock_analysis/brief.md.j2 create mode 100644 templates/stock_analysis/full.md.j2 create mode 100644 tests/e2e/test_hermes_llm.py create mode 100644 tests/e2e/test_host_to_python.py create mode 100644 tests/e2e/test_openclaw_e2e.ts create mode 100644 tests/e2e/test_pi_e2e.ts create mode 100644 tests/e2e/test_pi_llm.ts create mode 100644 tests/e2e/test_provider_network.py create mode 100644 tests/test_alert_rules.py create mode 100644 tests/test_backtest_advanced.py create mode 100644 tests/test_capabilities.py create mode 100644 tests/test_diagnostics.py create mode 100644 tests/test_import_parser.py create mode 100644 tests/test_position_context.py create mode 100644 tests/test_report_renderer.py create mode 100644 tests/test_watchlist_context.py create mode 100644 tools/alert_rules.py create mode 100644 tools/capabilities.py create mode 100644 tools/diagnostics.py create mode 100644 tools/import_parser.py create mode 100644 tools/position_context.py create mode 100644 tools/report_renderer.py create mode 100644 tools/watchlist_context.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d42193e..30df5c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,82 @@ jobs: - name: Test OpenClaw plugin registration run: npx tsx tests/test_openclaw_integration.ts + - name: E2E — host↔python round-trip (pytest) + run: pytest tests/e2e/test_host_to_python.py -v + + - name: E2E — Pi host real subprocess + run: npx tsx tests/e2e/test_pi_e2e.ts + + - name: E2E — OpenClaw host real subprocess + run: npx tsx tests/e2e/test_openclaw_e2e.ts + + e2e-network: + # Real akshare/yfinance calls. Marked opt-in; run in a dedicated job so + # provider outages fail loud without blocking the main test job. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install dependencies + run: | + pip install -r tools/requirements.txt + pip install -r requirements-dev.txt + + - name: E2E — real provider network + run: pytest tests/e2e/test_provider_network.py -m integration_network -v + + e2e-llm: + # Real DeepSeek call. Only runs when DEEPSEEK_API_KEY secret is present + # (skipped on forks to prevent secret exposure). + runs-on: ubuntu-latest + if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Python dependencies + run: | + pip install -r tools/requirements.txt + pip install -r requirements-dev.txt + + - name: Install Node dependencies + run: npm install + + - name: E2E — Hermes LLM flow + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + DEEPSEEK_MODEL: ${{ vars.DEEPSEEK_MODEL || 'deepseek-v4-flash' }} + run: | + if [ -z "$DEEPSEEK_API_KEY" ]; then + echo "DEEPSEEK_API_KEY not configured — skipping LLM e2e" + exit 0 + fi + pytest tests/e2e/test_hermes_llm.py -m integration_llm -v + + - name: E2E — Pi LLM flow + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + DEEPSEEK_MODEL: ${{ vars.DEEPSEEK_MODEL || 'deepseek-v4-flash' }} + run: | + if [ -z "$DEEPSEEK_API_KEY" ]; then + echo "DEEPSEEK_API_KEY not configured — skipping LLM e2e" + exit 0 + fi + npx tsx tests/e2e/test_pi_llm.ts + build: runs-on: ubuntu-latest needs: [lint, test, integration] diff --git a/hermes/__init__.py b/hermes/__init__.py index ec6579a..ff15001 100644 --- a/hermes/__init__.py +++ b/hermes/__init__.py @@ -37,6 +37,14 @@ "get_market_review": tools.get_market_review, "run_watchlist_analysis": tools.run_watchlist_analysis, "detect_anomaly": tools.detect_anomaly, + "diagnose_data_sources": tools.diagnose_data_sources, + "get_market_capabilities": tools.get_market_capabilities, + "render_stock_report": tools.render_stock_report, + "render_market_report": tools.render_market_report, + "build_watchlist_context": tools.build_watchlist_context, + "analyze_position_context": tools.analyze_position_context, + "check_alert_rules": tools.check_alert_rules, + "parse_stock_list": tools.parse_stock_list, } diff --git a/hermes/plugin.yaml b/hermes/plugin.yaml index af4de09..6d8aea2 100644 --- a/hermes/plugin.yaml +++ b/hermes/plugin.yaml @@ -33,6 +33,14 @@ provides_tools: - get_market_review - run_watchlist_analysis - detect_anomaly + - diagnose_data_sources + - get_market_capabilities + - render_stock_report + - render_market_report + - build_watchlist_context + - analyze_position_context + - check_alert_rules + - parse_stock_list requires_env: - name: TAVILY_API_KEY description: "Tavily search API key (optional, one search engine is enough)" diff --git a/hermes/schemas.py b/hermes/schemas.py index 490f18c..35d0371 100644 --- a/hermes/schemas.py +++ b/hermes/schemas.py @@ -570,4 +570,114 @@ "required": ["symbol"], }, }, + { + "name": "diagnose_data_sources", + "description": "数据源诊断 — 检查当前环境可用的数据 provider(akshare/tushare/yfinance/finnhub/longbridge/alphavantage),输出每个市场的可用链路、缺失 env、warnings。用于让 agent 自解释为何拿不到数据", + "parameters": { + "type": "object", + "properties": { + "market": { + "type": "string", + "enum": ["A", "HK", "US", "all"], + "description": "市场,默认 all", + }, + }, + }, + }, + { + "name": "get_market_capabilities", + "description": "市场能力边界 — 返回指定市场支持/不支持的工具列表,避免 agent 对港股调 get_chip_distribution 或对美股调 get_capital_flow 后编造数据", + "parameters": { + "type": "object", + "properties": { + "market": {"type": "string", "enum": ["A", "HK", "US"], "description": "市场代码"}, + "symbol": {"type": "string", "description": "股票代码(自动识别市场,与 market 二选一)"}, + }, + }, + }, + { + "name": "render_stock_report", + "description": "股票分析报告渲染 — 将结构化 JSON(符合 schemas/report_schema.json)通过 j2 模板渲染为 Markdown。template: brief|full。仅渲染,不保存不推送", + "parameters": { + "type": "object", + "properties": { + "report": {"type": "object", "description": "结构化股票报告,字段参考 schemas/report_schema.json"}, + "template": {"type": "string", "enum": ["brief", "full"], "description": "模板类型,默认 full"}, + }, + "required": ["report"], + }, + }, + { + "name": "render_market_report", + "description": "大盘复盘报告渲染 — 将结构化 JSON(符合 schemas/market_review_schema.json)通过 j2 模板渲染为 Markdown", + "parameters": { + "type": "object", + "properties": { + "report": {"type": "object", "description": "结构化市场复盘"}, + "template": {"type": "string", "enum": ["full"], "description": "模板类型,默认 full"}, + }, + "required": ["report"], + }, + }, + { + "name": "build_watchlist_context", + "description": "自选股上下文包 — 对多只股票输出评分/趋势/异常/风险/建议 next_tools 的 agent 友好摘要。宿主 agent 决定如何写日报或深入分析", + "parameters": { + "type": "object", + "properties": { + "symbols": {"type": "string", "description": "逗号分隔的股票代码列表"}, + "include_market_review": {"type": "boolean", "description": "是否附带各市场复盘,默认 false"}, + "workers": {"type": "number", "description": "并发数,默认 3"}, + }, + "required": ["symbols"], + }, + }, + { + "name": "analyze_position_context", + "description": "持仓上下文分析 — 输入成本/仓位/止损止盈,结合现价和技术位输出浮盈亏、离止损距离、风险级别、操作建议。无状态、不存账户", + "parameters": { + "type": "object", + "properties": { + "symbol": {"type": "string", "description": "股票代码"}, + "cost": {"type": "number", "description": "成本价"}, + "quantity": {"type": "number", "description": "持仓数量"}, + "stop_loss": {"type": "number", "description": "止损价(可选)"}, + "take_profit": {"type": "number", "description": "止盈价(可选)"}, + }, + "required": ["symbol", "cost", "quantity"], + }, + }, + { + "name": "check_alert_rules", + "description": "无状态告警规则检查 — 传入规则数组,返回当前是否触发。规则类型:price_below/price_above/change_pct_above/change_pct_below/volume_ratio_above/anomaly/risk_veto/risk_level_at_least。不做调度不存历史", + "parameters": { + "type": "object", + "properties": { + "symbol": {"type": "string", "description": "股票代码"}, + "rules": { + "type": "array", + "description": "规则列表,每项 { type, value }", + "items": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "value": {}, + }, + }, + }, + }, + "required": ["symbol", "rules"], + }, + }, + { + "name": "parse_stock_list", + "description": "自选股/文本导入解析 — 从自然语言、CSV、Markdown 表格提取股票,自动识别 A 股 6 位代码、港股 xxxxx.HK、美股 ticker,并调用 name_resolver 处理中文股票名", + "parameters": { + "type": "object", + "properties": { + "text": {"type": "string", "description": "待解析的文本"}, + }, + "required": ["text"], + }, + }, ] diff --git a/hermes/tools.py b/hermes/tools.py index c5472a4..e7aa9f8 100644 --- a/hermes/tools.py +++ b/hermes/tools.py @@ -1,3 +1,4 @@ +import base64 import json import subprocess import sys @@ -227,3 +228,63 @@ def run_watchlist_analysis(args: dict, **kwargs) -> str: def detect_anomaly(args: dict, **kwargs) -> str: symbol = args["symbol"] return _run("anomaly_detect.py", f"detect {symbol}") + + +def diagnose_data_sources(args: dict, **kwargs) -> str: + market = args.get("market", "all") + return _run("diagnostics.py", f"check --market {market}") + + +def get_market_capabilities(args: dict, **kwargs) -> str: + market = args.get("market") + symbol = args.get("symbol") + arg = f"--symbol {symbol}" if symbol else f"--market {market or 'A'}" + return _run("capabilities.py", f"get {arg}") + + +def render_stock_report(args: dict, **kwargs) -> str: + report = args["report"] + template = args.get("template", "full") + payload = base64.b64encode(json.dumps(report, ensure_ascii=False).encode("utf-8")).decode("ascii") + return _run("report_renderer.py", f"stock --template {template} --input-b64 {payload}") + + +def render_market_report(args: dict, **kwargs) -> str: + report = args["report"] + template = args.get("template", "full") + payload = base64.b64encode(json.dumps(report, ensure_ascii=False).encode("utf-8")).decode("ascii") + return _run("report_renderer.py", f"market --template {template} --input-b64 {payload}") + + +def build_watchlist_context(args: dict, **kwargs) -> str: + symbols = args["symbols"] + workers = args.get("workers", 3) + flag = " --include-market-review" if args.get("include_market_review") else "" + return _run("watchlist_context.py", f"build {symbols} --workers {workers}{flag}") + + +def analyze_position_context(args: dict, **kwargs) -> str: + symbol = args["symbol"] + cost = args["cost"] + quantity = args["quantity"] + stop_loss = args.get("stop_loss") + take_profit = args.get("take_profit") + sl_arg = f" --stop-loss {stop_loss}" if stop_loss is not None else "" + tp_arg = f" --take-profit {take_profit}" if take_profit is not None else "" + return _run( + "position_context.py", + f"analyze {symbol} --cost {cost} --quantity {quantity}{sl_arg}{tp_arg}", + ) + + +def check_alert_rules(args: dict, **kwargs) -> str: + symbol = args["symbol"] + rules = args["rules"] + payload = base64.b64encode(json.dumps(rules, ensure_ascii=False).encode("utf-8")).decode("ascii") + return _run("alert_rules.py", f"check {symbol} --rules-b64 {payload}") + + +def parse_stock_list(args: dict, **kwargs) -> str: + text = args["text"] + payload = base64.b64encode(str(text).encode("utf-8")).decode("ascii") + return _run("import_parser.py", f"parse --text-b64 {payload}") diff --git a/openclaw/index.ts b/openclaw/index.ts index 876bdc4..3386f24 100644 --- a/openclaw/index.ts +++ b/openclaw/index.ts @@ -757,5 +757,202 @@ export default definePluginEntry({ return asText(out); }, }); + + // --- Diagnostics & Capabilities --- + + api.registerTool({ + name: "diagnose_data_sources", + description: + "数据源诊断 — 检查当前环境可用的数据 provider(akshare/tushare/yfinance/finnhub/longbridge/alphavantage),输出每个市场的可用链路、缺失 env、warnings", + parameters: Type.Object({ + market: Type.Optional( + Type.Union( + [ + Type.Literal("A"), + Type.Literal("HK"), + Type.Literal("US"), + Type.Literal("all"), + ], + { description: "市场,默认 all" } + ) + ), + }), + async execute(_id, params) { + const out = await runPy("diagnostics.py", [ + "check", + "--market", + params.market ?? "all", + ]); + return asText(out); + }, + }); + + api.registerTool({ + name: "get_market_capabilities", + description: + "市场能力边界 — 返回指定市场支持/不支持的工具列表,避免 agent 对港股调 get_chip_distribution 或对美股调 get_capital_flow 后编造数据", + parameters: Type.Object({ + market: Type.Optional( + Type.Union( + [Type.Literal("A"), Type.Literal("HK"), Type.Literal("US")], + { description: "市场代码" } + ) + ), + symbol: Type.Optional( + Type.String({ description: "股票代码(自动识别市场,与 market 二选一)" }) + ), + }), + async execute(_id, params) { + const args = params.symbol + ? ["get", "--symbol", params.symbol] + : ["get", "--market", params.market ?? "A"]; + const out = await runPy("capabilities.py", args); + return asText(out); + }, + }); + + // --- Report Rendering --- + + api.registerTool({ + name: "render_stock_report", + description: + "股票分析报告渲染 — 将结构化 JSON(符合 schemas/report_schema.json)通过 j2 模板渲染为 Markdown。template: brief|full。仅渲染,不保存不推送", + parameters: Type.Object({ + report: Type.Object({}, { additionalProperties: true, description: "结构化股票报告" }), + template: Type.Optional( + Type.Union([Type.Literal("brief"), Type.Literal("full")], { + description: "模板类型,默认 full", + }) + ), + }), + async execute(_id, params) { + const b64 = Buffer.from(JSON.stringify(params.report), "utf-8").toString("base64"); + const out = await runPy("report_renderer.py", [ + "stock", + "--template", + params.template ?? "full", + "--input-b64", + b64, + ]); + return asText(out); + }, + }); + + api.registerTool({ + name: "render_market_report", + description: + "大盘复盘报告渲染 — 将结构化 JSON(符合 schemas/market_review_schema.json)通过 j2 模板渲染为 Markdown", + parameters: Type.Object({ + report: Type.Object({}, { additionalProperties: true, description: "结构化市场复盘" }), + template: Type.Optional( + Type.Union([Type.Literal("full")], { description: "模板类型,默认 full" }) + ), + }), + async execute(_id, params) { + const b64 = Buffer.from(JSON.stringify(params.report), "utf-8").toString("base64"); + const out = await runPy("report_renderer.py", [ + "market", + "--template", + params.template ?? "full", + "--input-b64", + b64, + ]); + return asText(out); + }, + }); + + // --- Watchlist / Position / Alert Context --- + + api.registerTool({ + name: "build_watchlist_context", + description: + "自选股上下文包 — 对多只股票输出评分/趋势/异常/风险/建议 next_tools 的 agent 友好摘要。宿主 agent 决定如何写日报或深入分析", + parameters: Type.Object({ + symbols: Type.String({ description: "逗号分隔的股票代码列表" }), + include_market_review: Type.Optional( + Type.Boolean({ description: "是否附带各市场复盘,默认 false" }) + ), + workers: Type.Optional(Type.Number({ description: "并发数,默认 3" })), + }), + async execute(_id, params) { + const args = [ + "build", + params.symbols, + "--workers", + String(params.workers ?? 3), + ]; + if (params.include_market_review) args.push("--include-market-review"); + const out = await runPy("watchlist_context.py", args); + return asText(out); + }, + }); + + api.registerTool({ + name: "analyze_position_context", + description: + "持仓上下文分析 — 输入成本/仓位/止损止盈,结合现价和技术位输出浮盈亏、离止损距离、风险级别、操作建议。无状态、不存账户", + parameters: Type.Object({ + symbol: Type.String({ description: "股票代码" }), + cost: Type.Number({ description: "成本价" }), + quantity: Type.Number({ description: "持仓数量" }), + stop_loss: Type.Optional(Type.Number({ description: "止损价" })), + take_profit: Type.Optional(Type.Number({ description: "止盈价" })), + }), + async execute(_id, params) { + const args = [ + "analyze", + params.symbol, + "--cost", + String(params.cost), + "--quantity", + String(params.quantity), + ]; + if (params.stop_loss !== undefined) { + args.push("--stop-loss", String(params.stop_loss)); + } + if (params.take_profit !== undefined) { + args.push("--take-profit", String(params.take_profit)); + } + const out = await runPy("position_context.py", args); + return asText(out); + }, + }); + + api.registerTool({ + name: "check_alert_rules", + description: + "无状态告警规则检查 — 传入规则数组,返回当前是否触发。规则类型:price_below/price_above/change_pct_above/change_pct_below/volume_ratio_above/anomaly/risk_veto/risk_level_at_least。不做调度不存历史", + parameters: Type.Object({ + symbol: Type.String({ description: "股票代码" }), + rules: Type.Array( + Type.Object({}, { additionalProperties: true }), + { description: "规则列表,每项 { type, value }" } + ), + }), + async execute(_id, params) { + const b64 = Buffer.from(JSON.stringify(params.rules), "utf-8").toString("base64"); + const out = await runPy("alert_rules.py", [ + "check", + params.symbol, + "--rules-b64", + b64, + ]); + return asText(out); + }, + }); + + api.registerTool({ + name: "parse_stock_list", + description: + "自选股/文本导入解析 — 从自然语言、CSV、Markdown 表格提取股票,自动识别 A 股 6 位代码、港股 xxxxx.HK、美股 ticker,并调用 name_resolver 处理中文股票名", + parameters: Type.Object({ + text: Type.String({ description: "待解析的文本" }), + }), + async execute(_id, params) { + const b64 = Buffer.from(String(params.text), "utf-8").toString("base64"); + const out = await runPy("import_parser.py", ["parse", "--text-b64", b64]); + return asText(out); + }, + }); }, }); diff --git a/openclaw/openclaw.plugin.json b/openclaw/openclaw.plugin.json index 424597e..72b9ef3 100644 --- a/openclaw/openclaw.plugin.json +++ b/openclaw/openclaw.plugin.json @@ -34,7 +34,15 @@ "detect_market_regime", "get_market_review", "run_watchlist_analysis", - "detect_anomaly" + "detect_anomaly", + "diagnose_data_sources", + "get_market_capabilities", + "render_stock_report", + "render_market_report", + "build_watchlist_context", + "analyze_position_context", + "check_alert_rules", + "parse_stock_list" ] }, "activation": { diff --git a/package-lock.json b/package-lock.json index 41ee3c5..a27b001 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "@weaxs/stock-analysis-plugin", - "version": "0.1.0", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@weaxs/stock-analysis-plugin", - "version": "0.1.0", + "version": "0.1.3", "hasInstallScript": true, "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", "jiti": "^2.7.0", + "openai": "^4.104.0", "tsx": "^4.22.1", "typescript": "^5.5.0" } @@ -468,6 +469,151 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", @@ -510,6 +656,54 @@ "@esbuild/win32-x64": "0.28.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -525,6 +719,120 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -535,6 +843,143 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, "node_modules/tsx": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz", @@ -574,6 +1019,34 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } } } } diff --git a/package.json b/package.json index 72eb193..c0a7279 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "tools/requirements.txt", "skills/", "schemas/", + "templates/", "strategies/", "scripts/" ], @@ -73,6 +74,7 @@ "devDependencies": { "@types/node": "^22.0.0", "jiti": "^2.7.0", + "openai": "^4.104.0", "tsx": "^4.22.1", "typescript": "^5.5.0" } diff --git a/pi/index.ts b/pi/index.ts index ce6b91e..8a72f3d 100644 --- a/pi/index.ts +++ b/pi/index.ts @@ -843,6 +843,200 @@ export default (pi: ExtensionAPI) => { }, }); + // --- Diagnostics & Capabilities --- + + pi.registerTool({ + name: "diagnose_data_sources", + description: + "数据源诊断 — 检查当前环境可用的数据 provider(akshare/tushare/yfinance/finnhub/longbridge/alphavantage),输出每个市场的可用链路、缺失 env、warnings。用于让 agent 自解释为何拿不到数据", + parameters: { + type: "object", + properties: { + market: { + type: "string", + enum: ["A", "HK", "US", "all"], + description: "市场,默认 all", + }, + }, + }, + async execute({ market = "all" }) { + const result = await py("diagnostics.py", `check --market ${market}`); + return result.stdout; + }, + }); + + pi.registerTool({ + name: "get_market_capabilities", + description: + "市场能力边界 — 返回指定市场支持/不支持的工具列表,避免 agent 对港股调 get_chip_distribution 或对美股调 get_capital_flow 后编造数据", + parameters: { + type: "object", + properties: { + market: { type: "string", enum: ["A", "HK", "US"], description: "市场代码" }, + symbol: { type: "string", description: "股票代码(自动识别市场,与 market 二选一)" }, + }, + }, + async execute({ market, symbol }) { + const arg = symbol ? `--symbol ${symbol}` : `--market ${market || "A"}`; + const result = await py("capabilities.py", `get ${arg}`); + return result.stdout; + }, + }); + + // --- Report Rendering --- + + pi.registerTool({ + name: "render_stock_report", + description: + "股票分析报告渲染 — 将结构化 JSON(符合 schemas/report_schema.json)通过 j2 模板渲染为 Markdown。style: brief|full。仅渲染,不保存不推送", + parameters: { + type: "object", + properties: { + report: { + type: "object", + description: "结构化股票报告,字段参考 schemas/report_schema.json", + }, + template: { + type: "string", + enum: ["brief", "full"], + description: "模板类型,默认 full", + }, + }, + required: ["report"], + }, + async execute({ report, template = "full" }) { + const b64 = Buffer.from(JSON.stringify(report), "utf-8").toString("base64"); + const result = await py( + "report_renderer.py", + `stock --template ${template} --input-b64 ${b64}` + ); + return result.stdout; + }, + }); + + pi.registerTool({ + name: "render_market_report", + description: + "大盘复盘报告渲染 — 将结构化 JSON(符合 schemas/market_review_schema.json)通过 j2 模板渲染为 Markdown", + parameters: { + type: "object", + properties: { + report: { type: "object", description: "结构化市场复盘" }, + template: { type: "string", enum: ["full"], description: "模板类型,默认 full" }, + }, + required: ["report"], + }, + async execute({ report, template = "full" }) { + const b64 = Buffer.from(JSON.stringify(report), "utf-8").toString("base64"); + const result = await py( + "report_renderer.py", + `market --template ${template} --input-b64 ${b64}` + ); + return result.stdout; + }, + }); + + // --- Watchlist / Position / Alert Context --- + + pi.registerTool({ + name: "build_watchlist_context", + description: + "自选股上下文包 — 对多只股票输出评分/趋势/异常/风险/建议 next_tools 的 agent 友好摘要。宿主 agent 决定如何写日报或深入分析", + parameters: { + type: "object", + properties: { + symbols: { type: "string", description: "逗号分隔的股票代码列表" }, + include_market_review: { + type: "boolean", + description: "是否附带各市场复盘,默认 false", + }, + workers: { type: "number", description: "并发数,默认 3" }, + }, + required: ["symbols"], + }, + async execute({ symbols, include_market_review = false, workers = 3 }) { + const flag = include_market_review ? " --include-market-review" : ""; + const result = await py( + "watchlist_context.py", + `build ${symbols} --workers ${workers}${flag}` + ); + return result.stdout; + }, + }); + + pi.registerTool({ + name: "analyze_position_context", + description: + "持仓上下文分析 — 输入成本/仓位/止损止盈,结合现价和技术位输出浮盈亏、离止损距离、风险级别、操作建议。无状态、不存账户", + parameters: { + type: "object", + properties: { + symbol: { type: "string", description: "股票代码" }, + cost: { type: "number", description: "成本价" }, + quantity: { type: "number", description: "持仓数量" }, + stop_loss: { type: "number", description: "止损价(可选)" }, + take_profit: { type: "number", description: "止盈价(可选)" }, + }, + required: ["symbol", "cost", "quantity"], + }, + async execute({ symbol, cost, quantity, stop_loss, take_profit }) { + const slArg = stop_loss !== undefined ? ` --stop-loss ${stop_loss}` : ""; + const tpArg = take_profit !== undefined ? ` --take-profit ${take_profit}` : ""; + const result = await py( + "position_context.py", + `analyze ${symbol} --cost ${cost} --quantity ${quantity}${slArg}${tpArg}` + ); + return result.stdout; + }, + }); + + pi.registerTool({ + name: "check_alert_rules", + description: + "无状态告警规则检查 — 传入规则数组,返回当前是否触发。规则类型:price_below/price_above/change_pct_above/change_pct_below/volume_ratio_above/anomaly/risk_veto/risk_level_at_least。不做调度不存历史", + parameters: { + type: "object", + properties: { + symbol: { type: "string", description: "股票代码" }, + rules: { + type: "array", + description: "规则列表,每项 { type, value }", + items: { + type: "object", + properties: { + type: { type: "string" }, + value: {}, + }, + }, + }, + }, + required: ["symbol", "rules"], + }, + async execute({ symbol, rules }) { + const b64 = Buffer.from(JSON.stringify(rules), "utf-8").toString("base64"); + const result = await py("alert_rules.py", `check ${symbol} --rules-b64 ${b64}`); + return result.stdout; + }, + }); + + pi.registerTool({ + name: "parse_stock_list", + description: + "自选股/文本导入解析 — 从自然语言、CSV、Markdown 表格提取股票,自动识别 A 股 6 位代码、港股 xxxxx.HK、美股 ticker,并调用 name_resolver 处理中文股票名", + parameters: { + type: "object", + properties: { + text: { type: "string", description: "待解析的文本" }, + }, + required: ["text"], + }, + async execute({ text }) { + const b64 = Buffer.from(String(text), "utf-8").toString("base64"); + const result = await py("import_parser.py", `parse --text-b64 ${b64}`); + return result.stdout; + }, + }); + // --- Skill Discovery --- pi.on("resources_discover", () => ({ diff --git a/pyproject.toml b/pyproject.toml index 511f609..fcdce46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dependencies = [ "pandas>=2.0.0", "numpy>=1.24.0", "requests>=2.28.0", + "jinja2>=3.1.0", ] [project.urls] @@ -65,7 +66,14 @@ packages = ["hermes", "tools"] [tool.hatch.build.targets.wheel.force-include] "skills" = "skills" +"templates" = "templates" +"schemas" = "schemas" [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] +addopts = "-m 'not integration_network and not integration_llm'" +markers = [ + "integration_network: real provider calls (akshare/yfinance/etc.) — needs network", + "integration_llm: real LLM calls (DeepSeek/Anthropic) — needs API key and network", +] diff --git a/requirements-dev.txt b/requirements-dev.txt index 8cd5cb6..6214cfb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,4 @@ pytest>=8.0 pytest-cov>=5.0 ruff>=0.4.0 +openai>=1.40.0 diff --git a/schemas/market_review_schema.json b/schemas/market_review_schema.json new file mode 100644 index 0000000..b9b74d7 --- /dev/null +++ b/schemas/market_review_schema.json @@ -0,0 +1,176 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MarketReviewReport", + "description": "结构化大盘复盘报告格式", + "type": "object", + "properties": { + "market": { + "type": "string", + "enum": ["A", "HK", "US"], + "description": "市场代码" + }, + "market_cn": { + "type": "string", + "description": "市场中文名(A 股 / 港股 / 美股)" + }, + "review_date": { "type": "string", "format": "date" }, + "temperature": { + "type": "object", + "description": "市场温度(来自 market_review.review)", + "properties": { + "score": { "type": "number", "minimum": 0, "maximum": 100 }, + "level": { "type": "string", "enum": ["constructive", "neutral", "weak"] }, + "signal": { "type": "string", "enum": ["green", "yellow", "red"] } + }, + "required": ["score", "signal"] + }, + "strategy_stance": { + "type": "string", + "enum": ["offensive", "balanced", "defensive"], + "description": "建议策略姿态" + }, + "regime": { + "type": "object", + "description": "市场阶段(来自 detect_market_regime)", + "properties": { + "regime": { "type": "string" }, + "regime_cn": { "type": "string" }, + "confidence": { "type": "number" }, + "recommended_skills": { "type": "array", "items": { "type": "string" } } + } + }, + "overview": { + "type": "object", + "description": "盘面总览", + "properties": { + "headline": { "type": "string", "description": "一句话总览" }, + "indices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "code": { "type": "string" }, + "price": { "type": "number" }, + "change_pct": { "type": "number" }, + "volume": { "type": ["number", "string"] } + } + } + }, + "breadth": { + "type": "object", + "description": "涨跌家数(仅 A 股)", + "properties": { + "up_count": { "type": "integer" }, + "down_count": { "type": "integer" }, + "limit_up_count": { "type": "integer" }, + "limit_down_count": { "type": "integer" }, + "turnover": { "type": ["number", "string"] }, + "turnover_change": { "type": "string", "description": "放量 / 缩量 / 持平" } + } + } + } + }, + "index_structure": { + "type": "object", + "description": "指数结构与分化", + "properties": { + "large_vs_small": { "type": "string", "description": "大盘 vs 中小盘表现差异" }, + "blue_chip_vs_theme": { "type": "string", "description": "权重股 vs 题材股分化" }, + "position_assessment": { "type": "string", "description": "指数位置(突破 / 回落 / 震荡)" } + } + }, + "sectors": { + "type": "object", + "description": "板块主线(仅 A 股有完整数据)", + "properties": { + "leading": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "change_pct": { "type": "number" }, + "logic": { "type": "string", "description": "上涨逻辑(政策 / 业绩 / 事件)" } + } + } + }, + "lagging": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "change_pct": { "type": "number" }, + "reason": { "type": "string" } + } + } + }, + "main_storylines": { + "type": "array", + "items": { + "type": "object", + "properties": { + "theme": { "type": "string" }, + "stage": { "type": "string", "description": "首日 / 加速 / 高潮 / 退潮" }, + "sustainability": { "type": "string", "enum": ["high", "medium", "low"] } + } + } + } + } + }, + "capital_emotion": { + "type": "object", + "description": "资金与情绪", + "properties": { + "turnover_level": { "type": "string", "description": "成交额历史分位" }, + "limit_ratio": { "type": "string", "description": "涨停 / 跌停比情绪解读" }, + "money_effect": { "type": "string", "description": "赚钱效应判断" }, + "consecutive_limit_height": { "type": "integer", "description": "连板高度(仅 A 股)" } + } + }, + "catalysts": { + "type": "array", + "description": "消息催化", + "items": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "category": { "type": "string", "description": "政策 / 业绩 / 事件 / 国际 / 其他" }, + "impact": { "type": "string", "description": "对市场的影响路径" }, + "direction": { "type": "string", "enum": ["positive", "negative", "neutral"] } + } + } + }, + "tomorrow_strategy": { + "type": "object", + "description": "明日策略", + "properties": { + "stance": { "type": "string", "enum": ["offensive", "balanced", "defensive"] }, + "position_advice": { "type": "string", "description": "建议仓位区间" }, + "focus_sectors": { "type": "array", "items": { "type": "string" } }, + "avoid": { "type": "array", "items": { "type": "string" } }, + "key_levels": { + "type": "object", + "properties": { + "support": { "type": ["number", "string"] }, + "resistance": { "type": ["number", "string"] } + } + } + } + }, + "risks": { + "type": "array", + "description": "风险提示", + "items": { + "type": "object", + "properties": { + "level": { "type": "string", "enum": ["high", "medium", "low"] }, + "category": { "type": "string", "description": "系统性 / 板块 / 个股 / 外围" }, + "description": { "type": "string" } + } + } + } + }, + "required": ["market", "review_date", "temperature", "strategy_stance"] +} diff --git a/templates/_macros.j2 b/templates/_macros.j2 new file mode 100644 index 0000000..a35b982 --- /dev/null +++ b/templates/_macros.j2 @@ -0,0 +1,90 @@ +{# Shared Jinja2 macros for stock-analysis-plugin report templates. #} + +{% macro decision_emoji(decision) -%} +{%- if decision == 'strong_buy' -%}🟢🟢 +{%- elif decision == 'buy' -%}🟢 +{%- elif decision == 'hold' -%}⚪ +{%- elif decision == 'sell' -%}🟡 +{%- elif decision == 'strong_sell' -%}🔴 +{%- else -%}❔ +{%- endif -%} +{%- endmacro %} + +{% macro decision_label(decision) -%} +{%- if decision == 'strong_buy' -%}强烈买入 +{%- elif decision == 'buy' -%}买入 +{%- elif decision == 'hold' -%}观望 +{%- elif decision == 'sell' -%}卖出 +{%- elif decision == 'strong_sell' -%}强烈卖出 +{%- else -%}- +{%- endif -%} +{%- endmacro %} + +{% macro confidence_label(c) -%} +{%- if c == 'high' -%}高 +{%- elif c == 'medium' -%}中 +{%- elif c == 'low' -%}低 +{%- else -%}- +{%- endif -%} +{%- endmacro %} + +{% macro signal_emoji(signal) -%} +{%- if signal == 'green' -%}🟢 +{%- elif signal == 'yellow' -%}🟡 +{%- elif signal == 'red' -%}🔴 +{%- else -%}⚪ +{%- endif -%} +{%- endmacro %} + +{% macro signal_label(signal) -%} +{%- if signal == 'green' -%}绿灯(进攻) +{%- elif signal == 'yellow' -%}黄灯(均衡) +{%- elif signal == 'red' -%}红灯(防守) +{%- else -%}- +{%- endif -%} +{%- endmacro %} + +{% macro stance_label(stance) -%} +{%- if stance == 'offensive' -%}进攻 +{%- elif stance == 'balanced' -%}均衡 +{%- elif stance == 'defensive' -%}防守 +{%- else -%}- +{%- endif -%} +{%- endmacro %} + +{% macro risk_emoji(level) -%} +{%- if level == 'high' -%}🔴 +{%- elif level == 'medium' -%}🟡 +{%- elif level == 'low' -%}🟢 +{%- else -%}⚪ +{%- endif -%} +{%- endmacro %} + +{% macro change_pct(v) -%} +{%- if v is none -%}- +{%- elif v > 0 -%}+{{ '%.2f'|format(v) }}% +{%- else -%}{{ '%.2f'|format(v) }}% +{%- endif -%} +{%- endmacro %} + +{% macro fmt_num(v, digits=2) -%} +{%- if v is none -%}- +{%- elif v is number -%}{{ ('%%.%df' % digits)|format(v) }} +{%- else -%}{{ v }} +{%- endif -%} +{%- endmacro %} + +{% macro market_cn(m) -%} +{%- if m == 'A' -%}A 股 +{%- elif m == 'HK' -%}港股 +{%- elif m == 'US' -%}美股 +{%- else -%}{{ m }} +{%- endif -%} +{%- endmacro %} + +{% macro direction_emoji(d) -%} +{%- if d == 'positive' -%}✅ +{%- elif d == 'negative' -%}⚠️ +{%- else -%}ℹ️ +{%- endif -%} +{%- endmacro %} diff --git a/templates/market_review/full.md.j2 b/templates/market_review/full.md.j2 new file mode 100644 index 0000000..de24a7b --- /dev/null +++ b/templates/market_review/full.md.j2 @@ -0,0 +1,103 @@ +{%- import '_macros.j2' as m -%} +# {{ m.market_cn(market) }}大盘复盘 · {{ review_date }} + +{% set t = temperature or {} -%} +**温度:** {{ m.signal_emoji(t.signal) }} {{ m.fmt_num(t.score, 1) }}/100 | **信号灯:** {{ m.signal_label(t.signal) }} | **策略姿态:** {{ m.stance_label(strategy_stance) }} + +{% if regime and (regime.regime_cn or regime.regime) -%} +> **市场阶段:** {{ regime.regime_cn or regime.regime }}{% if regime.confidence is not none %}(置信度 {{ m.fmt_num(regime.confidence) }}){% endif %}{% if regime.recommended_skills %} | **推荐策略:** {{ regime.recommended_skills | join('、') }}{% endif %} +{%- endif %} + +## 1. 盘面总览 + +{% set ov = overview or {} -%} +{% if ov.headline -%} +{{ ov.headline }} + +{% endif -%} +{% if ov.indices -%} +| 指数 | 现值 | 涨跌幅 | 成交量 | +|------|------|--------|--------| +{% for idx in ov.indices -%} +| {{ idx.name }}{% if idx.code %}({{ idx.code }}){% endif %} | {{ m.fmt_num(idx.price) }} | {{ m.change_pct(idx.change_pct) }} | {{ idx.volume or '-' }} | +{% endfor %} +{%- endif %} + +{% set br = ov.breadth or {} -%} +{% if br and (br.up_count is not none or br.limit_up_count is not none) -%} +- **涨跌家数:** 🟢 {{ br.up_count or 0 }} 上涨 | 🔴 {{ br.down_count or 0 }} 下跌 +- **涨跌停:** 🚀 {{ br.limit_up_count or 0 }} 涨停 | 💧 {{ br.limit_down_count or 0 }} 跌停 +{% if br.turnover %}- **成交额:** {{ br.turnover }}{% if br.turnover_change %}({{ br.turnover_change }}){% endif %} +{% endif -%} +{%- endif %} + +{% if index_structure -%} +## 2. 指数结构 + +{% if index_structure.large_vs_small %}- **大小盘分化:** {{ index_structure.large_vs_small }} +{% endif %}{% if index_structure.blue_chip_vs_theme %}- **权重 vs 题材:** {{ index_structure.blue_chip_vs_theme }} +{% endif %}{% if index_structure.position_assessment %}- **指数位置:** {{ index_structure.position_assessment }} +{% endif %} +{%- endif %} + +{% if sectors -%} +## 3. 板块主线 + +{% if sectors.leading -%} +**领涨板块:** +{% for s in sectors.leading %}- 🟢 {{ s.name }} {{ m.change_pct(s.change_pct) }}{% if s.logic %} — {{ s.logic }}{% endif %} +{% endfor %} +{%- endif %} + +{% if sectors.lagging -%} +**领跌板块:** +{% for s in sectors.lagging %}- 🔴 {{ s.name }} {{ m.change_pct(s.change_pct) }}{% if s.reason %} — {{ s.reason }}{% endif %} +{% endfor %} +{%- endif %} + +{% if sectors.main_storylines -%} +**主线判断:** +{% for line in sectors.main_storylines %}- **{{ line.theme }}** | 阶段:{{ line.stage or '-' }}{% if line.sustainability %} | 持续性:{{ line.sustainability }}{% endif %} +{% endfor %} +{%- endif %} +{%- endif %} + +{% if capital_emotion -%} +## 4. 资金与情绪 + +{% if capital_emotion.turnover_level %}- **成交分位:** {{ capital_emotion.turnover_level }} +{% endif %}{% if capital_emotion.limit_ratio %}- **涨跌停比:** {{ capital_emotion.limit_ratio }} +{% endif %}{% if capital_emotion.money_effect %}- **赚钱效应:** {{ capital_emotion.money_effect }} +{% endif %}{% if capital_emotion.consecutive_limit_height is not none %}- **连板高度:** {{ capital_emotion.consecutive_limit_height }} 板 +{% endif %} +{%- endif %} + +{% if catalysts -%} +## 5. 消息催化 + +{% for c in catalysts %}- {{ m.direction_emoji(c.direction) }} **[{{ c.category or '-' }}] {{ c.title }}**{% if c.impact %} — {{ c.impact }}{% endif %} +{% endfor %} +{%- endif %} + +{% if tomorrow_strategy -%} +## 6. 明日策略 + +{% set ts = tomorrow_strategy -%} +- **策略姿态:** {{ m.stance_label(ts.stance) }} +{% if ts.position_advice %}- **建议仓位:** {{ ts.position_advice }} +{% endif %}{% if ts.focus_sectors %}- **关注方向:** {{ ts.focus_sectors | join('、') }} +{% endif %}{% if ts.avoid %}- **规避方向:** {{ ts.avoid | join('、') }} +{% endif %}{% if ts.key_levels and (ts.key_levels.support or ts.key_levels.resistance) -%} +- **关键点位:** 支撑 {{ ts.key_levels.support or '-' }} | 压力 {{ ts.key_levels.resistance or '-' }} +{%- endif %} +{%- endif %} + +{% if risks -%} +## 7. 风险提示 + +{% for r in risks %}- {{ m.risk_emoji(r.level) }} **[{{ r.category or '风险' }}]** {{ r.description }} +{% endfor %} +{%- endif %} + +--- +*本报告由 stock-analysis-plugin 渲染,仅供参考,不构成投资建议。* diff --git a/templates/stock_analysis/brief.md.j2 b/templates/stock_analysis/brief.md.j2 new file mode 100644 index 0000000..43170d5 --- /dev/null +++ b/templates/stock_analysis/brief.md.j2 @@ -0,0 +1,28 @@ +{%- import '_macros.j2' as m -%} +{{ m.decision_emoji(decision_type) }} **{{ stock_name }}({{ stock_code }})** | {{ m.decision_label(decision_type) }} · 评分 {{ sentiment_score }} · 置信 {{ m.confidence_label(confidence) }} + +{% if core_conclusion and core_conclusion.one_sentence -%} +> {{ core_conclusion.one_sentence }} +{%- endif %} +{% if risk_screening and risk_screening.veto_buy %} +⚠️ **一票否决** — 风险筛查触发,建议谨慎。 +{% endif %} + +{% set dp = data_perspective or {} -%} +{% set pp = dp.price_position or {} -%} +{% set ts = dp.trend_status or {} -%} +- 📈 **趋势:** {{ ts.ma_alignment or '-' }}{% if ts.is_bullish is not none %}({{ '看多' if ts.is_bullish else '看空' }}){% endif %} +- 💰 **价位:** 现价 {{ m.fmt_num(pp.current_price) }} | 支撑 {{ m.fmt_num(pp.support_level) }} | 压力 {{ m.fmt_num(pp.resistance_level) }} + +{% set bp = battle_plan or {} -%} +{% if bp -%} +- 🎯 **作战:** 买点 {{ m.fmt_num(bp.ideal_buy) }} | 止损 {{ m.fmt_num(bp.stop_loss) }} | 止盈 {{ m.fmt_num(bp.take_profit) }} | 仓位 {{ bp.suggested_position or '-' }} +{%- endif %} + +{% set intel = intelligence or {} -%} +{% if intel.positive_catalysts -%} +✅ **利好:** {{ intel.positive_catalysts | join(';') }} +{% endif %} +{% if intel.risk_alerts -%} +⚠️ **风险:** {{ intel.risk_alerts | join(';') }} +{% endif %} diff --git a/templates/stock_analysis/full.md.j2 b/templates/stock_analysis/full.md.j2 new file mode 100644 index 0000000..8dadb71 --- /dev/null +++ b/templates/stock_analysis/full.md.j2 @@ -0,0 +1,108 @@ +{%- import '_macros.j2' as m -%} +# {{ stock_name }}({{ stock_code }})分析报告 + +**分析日期:** {{ analysis_date }} | **决策:** {{ m.decision_emoji(decision_type) }} {{ m.decision_label(decision_type) }} | **情绪分:** {{ sentiment_score }}/100 | **置信度:** {{ m.confidence_label(confidence) }} + +{% if core_conclusion -%} +> **核心结论:** {{ core_conclusion.one_sentence }} +{% if core_conclusion.signal_type %}> **信号类型:** {{ core_conclusion.signal_type }}{% if core_conclusion.time_sensitivity %} | **时效:** {{ core_conclusion.time_sensitivity }}{% endif %}{% endif %} +{%- endif %} + +{% if risk_screening and risk_screening.veto_buy %} +> ⚠️ **一票否决(veto_buy):** 风险筛查触发否决条件,建议谨慎。 +{% endif %} + +## 市场环境 + +{% if market_environment -%} +- **市场阶段:** {{ market_environment.regime_cn or market_environment.regime or '-' }}{% if market_environment.confidence is not none %}(置信度 {{ m.fmt_num(market_environment.confidence) }}){% endif %} +{% if market_environment.recommended_skills -%} +- **推荐策略:** {{ market_environment.recommended_skills | join('、') }} +{%- endif %} +{%- else -%} +- 数据缺失 +{%- endif %} + +## 风险筛查 + +{% if risk_screening -%} +- **风险等级:** {{ m.risk_emoji(risk_screening.risk_level) }} {{ risk_screening.risk_level or '-' }} | **风险分:** {{ risk_screening.risk_score }}/100 +{% if risk_screening.flags -%} +- **风险标记:** +{% for f in risk_screening.flags %} - [{{ f.severity }}] {{ f.category }}:{{ f.description }} +{% endfor %} +{%- else -%} +- **风险标记:** 无显著风险标记 +{%- endif %} +{%- else -%} +- 数据缺失 +{%- endif %} + +## 技术分析 + +{% set dp = data_perspective or {} -%} +{% set ts = dp.trend_status or {} -%} +{% set pp = dp.price_position or {} -%} +{% set vol = dp.volume_analysis or {} -%} +{% set chip = dp.chip_structure or {} -%} +- **趋势:** {{ ts.ma_alignment or '-' }}{% if ts.is_bullish is not none %}({{ '看多' if ts.is_bullish else '看空' }}){% endif %}{% if ts.trend_score is not none %} 趋势分 {{ m.fmt_num(ts.trend_score) }}{% endif %} +- **价格结构:** 现价 {{ m.fmt_num(pp.current_price) }} | 偏离 MA5 {{ m.change_pct(pp.bias_ma5) }} | 支撑 {{ m.fmt_num(pp.support_level) }} | 压力 {{ m.fmt_num(pp.resistance_level) }} +- **量价:** 量比 {{ m.fmt_num(vol.volume_ratio) }}{% if vol.volume_status %}({{ vol.volume_status }}){% endif %}{% if vol.turnover_rate is not none %} | 换手率 {{ m.change_pct(vol.turnover_rate) }}{% endif %} +{% if chip and (chip.profit_ratio is not none or chip.avg_cost is not none) -%} +- **筹码:** 获利盘 {{ m.change_pct(chip.profit_ratio) }} | 平均成本 {{ m.fmt_num(chip.avg_cost) }}{% if chip.concentration is not none %} | 集中度 {{ m.fmt_num(chip.concentration) }}{% endif %} +{%- endif %} + +## 消息面 + +{% set intel = intelligence or {} -%} +{% if intel.latest_news -%} +- **最新动态:** {{ intel.latest_news }} +{%- endif %} +{% if intel.sentiment_summary -%} +- **舆情摘要:** {{ intel.sentiment_summary }} +{%- endif %} +{% if intel.positive_catalysts -%} +- **利好催化:** +{% for c in intel.positive_catalysts %} - ✅ {{ c }} +{% endfor %} +{%- endif %} +{% if intel.risk_alerts -%} +- **风险提示:** +{% for a in intel.risk_alerts %} - ⚠️ {{ a }} +{% endfor %} +{%- endif %} + +## 作战计划 + +{% set bp = battle_plan or {} -%} +{% if bp -%} +- **理想买点:** {{ m.fmt_num(bp.ideal_buy) }} | **次级买点:** {{ m.fmt_num(bp.secondary_buy) }} +- **止损:** {{ m.fmt_num(bp.stop_loss) }} | **止盈:** {{ m.fmt_num(bp.take_profit) }} +- **建议仓位:** {{ bp.suggested_position or '-' }} +{% if bp.entry_plan %}- **进场计划:** {{ bp.entry_plan }} +{% endif %}{% if bp.risk_control %}- **风控要点:** {{ bp.risk_control }} +{% endif %}{% if bp.action_checklist -%} +- **操作清单:** +{% for item in bp.action_checklist %} - [ ] {{ item }} +{% endfor %} +{%- endif %} +{%- else -%} +- 暂无作战计划 +{%- endif %} + +{% if core_conclusion and core_conclusion.position_advice -%} +## 仓位建议 + +- **空仓者:** {{ core_conclusion.position_advice.no_position or '-' }} +- **持仓者:** {{ core_conclusion.position_advice.has_position or '-' }} +{%- endif %} + +{% if risk_warning -%} +## 风险提示 + +{% for w in risk_warning %}- {{ w }} +{% endfor %} +{%- endif %} + +--- +*本报告由 stock-analysis-plugin 渲染,仅供参考,不构成投资建议。* diff --git a/tests/e2e/test_hermes_llm.py b/tests/e2e/test_hermes_llm.py new file mode 100644 index 0000000..6f91081 --- /dev/null +++ b/tests/e2e/test_hermes_llm.py @@ -0,0 +1,167 @@ +"""Layer 3 e2e: LLM → tool_use → real Hermes handler → answer. + +Verifies the full agentic loop works: DeepSeek gets a natural-language task, +decides to call our tools, we execute them via Hermes handlers, and DeepSeek +produces a final answer. + +Requires DEEPSEEK_API_KEY. Marked integration_llm — skipped by default. + +Uses OpenAI SDK against DeepSeek's OpenAI-compatible endpoint. +Model: DEEPSEEK_MODEL env var (default: deepseek-v4-flash). +""" + +import json +import os + +import pytest + +pytestmark = pytest.mark.integration_llm + + +DEEPSEEK_BASE_URL = "https://api.deepseek.com" +DEEPSEEK_MODEL = os.environ.get("DEEPSEEK_MODEL", "deepseek-v4-flash") + + +@pytest.fixture(scope="module") +def client(): + """OpenAI-compatible client for DeepSeek.""" + api_key = os.environ.get("DEEPSEEK_API_KEY") + if not api_key: + pytest.fail("DEEPSEEK_API_KEY not set — layer 3 e2e requires a real API key") + try: + from openai import OpenAI + except ImportError: + pytest.fail("openai SDK not installed. add to requirements-dev.txt") + return OpenAI(api_key=api_key, base_url=DEEPSEEK_BASE_URL) + + +@pytest.fixture(scope="module") +def hermes_handlers(): + """Build the same handler map Hermes exposes to a live agent.""" + from hermes import register + + class Ctx: + def __init__(self): + self.handlers = {} + self.schemas = {} + + def register_tool(self, name, toolset, schema, handler): + self.handlers[name] = handler + self.schemas[name] = schema + + def register_skill(self, name, path): + pass + + ctx = Ctx() + register(ctx) + return ctx + + +def _to_openai_tools(schemas: dict, names: list[str]) -> list[dict]: + """Convert Hermes schema dicts → OpenAI tools[] format.""" + return [ + { + "type": "function", + "function": { + "name": schemas[n]["name"], + "description": schemas[n]["description"], + "parameters": schemas[n]["parameters"], + }, + } + for n in names + ] + + +def _run_agent_loop(client, hermes_ctx, user_msg: str, tool_names: list[str], max_turns: int = 3) -> dict: + """One-shot agent loop: LLM → tool_calls → handler → LLM → final answer.""" + tools = _to_openai_tools(hermes_ctx.schemas, tool_names) + messages = [{"role": "user", "content": user_msg}] + + tool_calls_seen: list[dict] = [] + + for _ in range(max_turns): + resp = client.chat.completions.create( + model=DEEPSEEK_MODEL, + messages=messages, + tools=tools, + tool_choice="auto", + ) + msg = resp.choices[0].message + # Append the assistant message (must include tool_calls if present) + messages.append(msg.model_dump(exclude_none=True)) + + if not msg.tool_calls: + return { + "final": msg.content or "", + "tool_calls": tool_calls_seen, + "turns": len(messages), + } + + for call in msg.tool_calls: + name = call.function.name + args = json.loads(call.function.arguments or "{}") + tool_calls_seen.append({"name": name, "args": args}) + + if name not in hermes_ctx.handlers: + result = json.dumps({"error": f"unknown tool: {name}"}) + else: + result = hermes_ctx.handlers[name](args) + + messages.append( + { + "role": "tool", + "tool_call_id": call.id, + "content": result, + } + ) + + return { + "final": "", + "tool_calls": tool_calls_seen, + "turns": max_turns, + "note": "hit max_turns without final answer", + } + + +class TestSingleToolFlow: + """Simplest possible e2e: LLM must pick parse_stock_list for a symbol-extract task.""" + + def test_parse_stock_list_flow(self, client, hermes_handlers): + result = _run_agent_loop( + client, + hermes_handlers, + user_msg="从这段文字里找出所有股票代码:我想看茅台 600519 和 AAPL 今天怎么样。只列出代码。", + tool_names=["parse_stock_list"], + ) + + # Must have called parse_stock_list at least once + names_called = [tc["name"] for tc in result["tool_calls"]] + assert "parse_stock_list" in names_called, ( + f"LLM did not call parse_stock_list. tool_calls={result['tool_calls']}" + ) + + # Final answer should mention both symbols + final = result["final"] + assert "600519" in final, f"final answer missing 600519: {final}" + assert "AAPL" in final, f"final answer missing AAPL: {final}" + + +class TestMultiToolFlow: + """LLM picks the right tool from a set. Static-data tools only to avoid network flake.""" + + def test_capabilities_flow(self, client, hermes_handlers): + result = _run_agent_loop( + client, + hermes_handlers, + user_msg="港股支持获取资金流数据(capital_flow)吗?用工具查一下再回答,答案只需 是/否。", + tool_names=["get_market_capabilities"], + ) + + names_called = [tc["name"] for tc in result["tool_calls"]] + assert "get_market_capabilities" in names_called, f"LLM did not query capabilities: {result['tool_calls']}" + + # capital_flow is A-share only → answer should be no/否 + final_lower = result["final"].lower() + assert "否" in result["final"] or "不支持" in result["final"] or "no" in final_lower, ( + f"LLM should answer negative but said: {result['final']}" + ) diff --git a/tests/e2e/test_host_to_python.py b/tests/e2e/test_host_to_python.py new file mode 100644 index 0000000..d6aa7cb --- /dev/null +++ b/tests/e2e/test_host_to_python.py @@ -0,0 +1,143 @@ +"""Layer 1 e2e: host handler → subprocess → real Python tool → JSON round-trip. + +Verifies that pi/hermes/openclaw all wire the SAME 4 non-network tools correctly. +No akshare/yfinance/LLM calls — pure host↔python plumbing. + +Covers 4 tools chosen because they don't need external data: +- get_market_capabilities (static map) +- parse_stock_list (regex + resolver monkeypatch) +- render_stock_report (jinja only) +- diagnose_data_sources (pkg/env probe) +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +TOOLS_DIR = PROJECT_ROOT / "tools" + + +def _run_cli(script: str, args: list[str], timeout: int = 30) -> dict: + """Run tools/