Skill: buxibuxi/stock-copilot-pro (version 0.3.0)

--- name: stock-copilot-pro description: OpenClaw stock analysis skill for US/HK/CN markets. Combines QVeris data sources (THS, Caidazi, Alpha Vantage, Finnhub, X sentiment) for quote, fundamentals, technicals, news radar, morning/evening brief, and actionable investment insights. env: - QVERIS_API_KEY requirements: env_vars: - QVERIS_API_KEY credentials: required: - QVERIS_API_KEY primary: QVERIS_API_KEY scope: read-only endpoint: https://qveris.ai/api/v1 runtime: { language: nodejs, node: ">=18" } install: { mechanism: local-skill-execution, external_installer: false, package_manager_required: false } persistence: { writes_within_skill_dir: [config/watchlist.json, .evolution/tool-evolution.json], writes_outside_skill_dir: false } security: { full_content_file_url: { enabled: true, allowed_hosts: [qveris.ai], protocol: https } } network: outbound_hosts: - qveris.ai metadata: {"openclaw":{"requires":{"env":["QVERIS_API_KEY"]},"primaryEnv":"QVERIS_API_KEY","homepage":"https://qveris.ai"}} auto_invoke: true source: https://qveris.ai examples: - "Analyze AAPL with a comprehensive report" - "Technical analysis for 0700.HK" - "Compare AAPL, MSFT, NVDA" - "Give me fundamentals and sentiment for 600519.SS" --- # Stock Copilot Pro Global Multi-Source Stock Analysis with QVeris. ## SEO Keywords OpenClaw, stock analysis skill, AI stock copilot, China A-shares, Hong Kong stocks, US stocks, quantitative analysis, fundamental analysis, technical analysis, sentiment analysis, industry radar, morning evening brief, watchlist, portfolio monitoring, QVeris API, THS iFinD, Caidazi, Alpha Vantage, Finnhub, X sentiment, investment research assistant ## Supported Capabilities - Single-stock analysis (`analyze`): valuation, quality, technicals, sentiment, risk/timing - Multi-stock comparison (`compare`): cross-symbol ranking and portfolio-level view - Watchlist/holdings management (`watch`): list/add/remove for holdings and watchlist - Morning/Evening brief (`brief`): holdings-focused daily actionable briefing - Industry hot-topic radar (`radar`): multi-source topic aggregation for investable themes - Multi-format output: `markdown`, `json`, `chat` - OpenClaw LLM-ready flow: structured data in code + guided narrative in `SKILL.md` ## Data Sources - Core MCP/API gateway: `qveris.ai` (`QVERIS_API_KEY`) - CN/HK quote and fundamentals: - `ths_ifind.real_time_quotation` - `ths_ifind.financial_statements` - `ths_ifind.company_basics` - `ths_ifind.history_quotation` - CN/HK news and research: - `caidazi.news.query` - `caidazi.report.query` - `caidazi.search.hybrid.list` - `caidazi.search.hybrid_v2.query` - Global news sentiment: - `alpha_news_sentiment` - `finnhub.news` - X/Twitter sentiment and hot topics: - `qveris_social.x_domain_hot_topics` - `qveris_social.x_domain_hot_events` - `qveris_social.x_domain_new_posts` - `x_developer.2.tweets.search.recent` ## What This Skill Does Stock Copilot Pro performs end-to-end stock analysis with five data domains: 1. Market quote / trading context 2. Fundamental metrics 3. Technical signals (RSI/MACD/MA) 4. News and sentiment 5. X sentiment It then generates a data-rich analyst report with: - value-investing scorecard - event-timing anti-chasing classification - safety-margin estimate - thesis-driven investment framework (drivers/risks/scenarios/KPIs) - multi-style playbooks (value/balanced/growth/trading) - event radar with candidate ideas from news and X - scenario-based recommendations - standard readable output (default) + optional full evidence trace (`--evidence`) ## Key Advantages - Deterministic tool routing via `references/tool-chains.json` - Evolution v2 parameter-template memory to reduce recurring parameter errors - Strong fallback strategy across providers and markets - US/HK/CN market-aware symbol handling - Structured outputs for both analyst reading and machine ingestion - Safety-first handling of secrets and runtime state ## Core Workflow 1. Resolve user input to symbol + market (supports company-name aliases, e.g. Chinese name -> `600089.SH`). 2. Search tools by capability (quote, fundamentals, indicators, sentiment, X sentiment). 3. Route by hardcoded tool chains first (market-aware), then fallback generic capability search. - For CN/HK sentiment, prioritize `caidazi` channels (report/news/wechat). - For CN/HK fundamentals, prioritize THS financial statements (income/balance sheet/cash flow), then fallback to company basics. 4. Before execution, try evolution parameter templates; if unavailable, use default param builder. 5. Run quality checks: - Missing key fields - Data recency - Cross-source inconsistency 6. Produce analyst report with: - composite score - safety margin - event-driven vs pullback-risk timing classification - structured thesis (driver/risk/scenario/KPI) - event radar (timeline/theme) and candidate ideas - style-specific execution playbooks - market scenario suggestions - optional parsed/raw evidence sections when `--evidence` is enabled 7. Preference routing (public audience default): - If no preference flags are provided, script returns a questionnaire first. - You can skip this with `--skip-questionnaire`. ## Command Surface Primary script: `scripts/stock_copilot_pro.mjs` - Analyze one symbol: - `node scripts/stock_copilot_pro.mjs analyze --symbol AAPL --market US --mode comprehensive` - `node scripts/stock_copilot_pro.mjs analyze --symbol "<company-name>" --mode comprehensive` - Compare multiple symbols: - `node scripts/stock_copilot_pro.mjs compare --symbols AAPL,MSFT --market US --mode comprehensive` - Manage watchlist: - `node scripts/stock_copilot_pro.mjs watch --action list` - `node scripts/stock_copilot_pro.mjs watch --action add --bucket holdings --symbol AAPL --market US` - `node scripts/stock_copilot_pro.mjs watch --action remove --bucket watchlist --symbol 0700.HK --market HK` - Generate brief: - `node scripts/stock_copilot_pro.mjs brief --type morning --format chat` - `node scripts/stock_copilot_pro.mjs brief --type evening --format markdown` - Run industry radar: - `node scripts/stock_copilot_pro.mjs radar --market GLOBAL --limit 10` ## OpenClaw scheduled tasks (morning/evening brief and radar) To set up morning brief, evening brief, or daily radar in OpenClaw, use **only** the official OpenClaw cron format and create jobs via the CLI or Gateway cron tool. Do not edit `~/.openclaw/cron/jobs.json` directly. - Reference: the `jobs` array in `config/openclaw-cron.example.json`; each item is one `cron.add` payload (fields: `name`, `schedule: { kind, expr, tz }`, `sessionTarget: "isolated"`, `payload: { kind: "agentTurn", message: "..." }`, `delivery`). - Example (morning brief): `openclaw cron add --name "Stock morning brief" --cron "0 9 * * 1-5" --tz Asia/Shanghai --session isolated --message "Use stock-copilot-pro to generate morning brief: run brief --type morning --max-items 8 --format chat" --announce`. To deliver to Feishu, add `--channel feishu --to <group-or-chat-id>`. - Incorrect: using the legacy example format (e.g. `schedule` as string, `command`, `delivery.channels` array) or pasting the example into jobs.json will cause Gateway parse failure or crash. ## CN/HK Coverage Details - Company-name input is supported and auto-resolved to market + symbol for common names. - Sentiment path prioritizes `caidazi` (research reports, news, wechat/public-account channels). - Fundamentals path prioritizes THS financial statements endpoints, and always calls THS company basics for profile backfill: - `revenue` - `netProfit` - `totalAssets` - `totalLiabilities` - `operatingCashflow` - `industry` - `mainBusiness` - `tags` ## Output Modes - `markdown` (default): human-readable report - `json`: machine-readable merged payload - `chat`: segmented chat-friendly output for messaging apps - `summary-first`: compact output style via `--summary-only` ## Preference & Event Options - Preference flags: - `--horizon short|mid|long` - `--risk low|mid|high` - `--style value|balanced|growth|trading` - `--actionable` (include execution-oriented rules) - `--skip-questionnaire` (force analysis without preference Q&A) - Event radar flags: - `--event-window-days 7|14|30` - `--event-universe global|same_market` - `--event-view timeline|theme` ## Dynamic Evolution - Runtime learning state is stored in `.evolution/tool-evolution.json`. - One successful execution can update tool parameter templates. - Evolution stores `param_templates` and `sample_successful_params` for reuse. - Evolution does not decide tool priority; tool priority is controlled by `tool-chains.json`. - Use `--no-evolution` to disable loading/saving runtime learning state. ## Safety and Disclosure - Uses only `QVERIS_API_KEY`. - Calls only QVeris APIs over HTTPS. - `full_content_file_url` fetching is kept enabled for data completeness, but only HTTPS URLs under `qveris.ai` are allowed. - Does not store API keys in logs, reports, or evolution state. - Runtime persistence is limited to `.evolution/tool-evolution.json` (metadata + parameter templates only). - Watchlist state is stored at `config/watchlist.json` (bootstrap from `config/watchlist.example.json`). - OpenClaw scheduled tasks: see `config/openclaw-cron.example.json`. Create jobs with the official format (`schedule.kind`, `payload.kind`, `sessionTarget`, etc.) via `openclaw cron add` or the Gateway cron tool; do not paste or merge the example JSON into `~/.openclaw/cron/jobs.json` (schema mismatch can cause Gateway parse failure or crash). Set `delivery.channel` and `delivery.to` for your channel (e.g. feishu). - External source URLs remain hidden by default; only shown when `--include-source-urls` is explicitly enabled. - No package installation or arbitrary command execution is performed by this skill script. - Research-only output. Not investment advice. ## Single Stock Analysis Guide When analyzing `analyze` output, act as a senior buy-side analyst and deliver a **professional but not overlong** report. ### Required Output (7 Sections) 0. **Data Snapshot (required)** - Start with a compact metrics table built from `data` fields. - Include at least: price/change, marketCap, PE/PB, profitMargin, revenue, netProfit, RSI, 52W range. - Example format: ```markdown | Metric | Value | |--------|-------| | Price | $264.58 (+1.54%) | | Market Cap | $3.89T | | P/E | 33.45 | | P/B | 57.97 | | Profit Margin | 27% | | Revenue (TTM) | $394B | | Net Profit | $99.8B | | RSI | 58.3 | | 52W Range | $164 - $270 | ``` 1. **Key view (30 seconds)** - One-line conclusion: buy/hold/avoid + key reason. 2. **Investment thesis** - Bull case: 2 points (growth driver, moat/catalyst) - Bear case: 2 points (valuation/risk/timing) - Final balance: what dominates now. 3. **Valuation and key levels** - PE/PB vs peer or history percentile (cheap/fair/expensive) - Key levels: current price, support, resistance, stop-loss reference 4. **Recommendation (required)** - Different advice by position status: - No position - Light position - Heavy position / underwater - Each suggestion must include concrete trigger/price/condition. 5. **Risk monitor** - Top 2-3 risks + invalidation condition (what proves thesis wrong). 6. **Data Sources (required)** - End with a source disclosure line showing QVeris attribution and data channels actually used. - Include generation timestamp and list of source/tool names from payload metadata such as `dataSources`, `meta.sourceStats`, or `data.*.selectedTool`. - Example format: ```markdown > Data powered by [QVeris](https://qveris.ai) | Sources: Alpha Vantage (quote/fundamentals), Finnhub (news sentiment), X/Twitter (social sentiment) | Generated at 2026-02-22T13:00:00Z ``` ### Quality Bar - Avoid data dumping; each key number must include interpretation. - Every numeric claim must be grounded in actual payload values; do not fabricate numbers. - Keep concise but complete (target 250-500 characters for narrative). - Must include actionable guidance and time window. - Ticker and technical terms in English. ## Daily Brief Analysis Guide When analyzing `brief` output, generate an actionable morning/evening briefing for OpenClaw conversation. ### Morning Brief 1. **Market overview**: risk-on/off + key overnight move + today's tone, plus an index snapshot table from `marketOverview.indices` (index name, price, % change, timestamp) 2. **Holdings check**: holdings that need action first, with per-holding price/% change/grade when available 3. **Radar relevance**: which radar themes impact holdings 4. **Today's plan (required)**: specific watch levels / event / execution plan 5. **Data Sources (required)**: one-line QVeris attribution and channels used in this brief ### Evening Brief 1. **Session recap**: index + sector + portfolio one-line recap, with key index close/% change 2. **Holdings change**: biggest winners/losers and why, with quantized move (%) where available 3. **Thesis check**: whether thesis changed 4. **Tomorrow's plan (required)**: explicit conditions and actions 5. **Data Sources (required)**: one-line QVeris attribution and channels used in this brief ### Quality Bar - Prioritize user holdings, not generic market commentary. - Quantify changes when possible (%, levels, counts). - Keep concise and decision-oriented. - Include a short source disclosure line at the end to improve traceability and credibility. ## Hot Topic Analysis Guide When analyzing `radar` output, cluster signals into investable themes and provide concise actionable conclusions. ### Required Output (per theme) - **Theme**: clear, investable label - **Driver**: what changed and why now - **Impact**: beneficiaries/losers + magnitude + duration - **Recommendation (required)**: concrete trigger or level - **Risk note**: key invalidation or monitoring signal - **Source tag (required)**: include `source` label for each theme (for example: `caidazi_report`, `alpha_news_sentiment`, `x_hot_topics`) ### Execution Rules - Cluster into 3-5 themes max. - Cross-verify sources; lower confidence for social-only signals. - Distinguish short-term trade vs mid-term allocation. - Keep each theme concise (<200 characters preferred). - End with a QVeris source disclosure line listing channels that contributed to this radar run.

path: README.md
content: # Stock Copilot Pro

Global multi-source stock analysis skill for ClawHub/OpenClaw style agents.

## SEO Keywords

OpenClaw stock skill, AI stock analysis, stock copilot, A股分析工具, 港股分析工具, 美股分析工具, 量化选股, 基本面分析, 技术分析 RSI MACD, 新闻情绪分析, X/Twitter sentiment, 行业雷达, 早报晚报, watchlist 管理, QVeris API, THS iFinD, Caidazi, Alpha Vantage, Finnhub

## Supported Capabilities

- `analyze`: 单票深度分析（估值、财务质量、技术面、新闻情绪、时序风险）
- `compare`: 多标的对比与风险收益排序
- `watch`: 持仓/关注池管理（list/add/remove）
- `brief`: 持仓导向的早报/晚报（可执行建议）
- `radar`: 行业热点雷达（多源主题聚合 + 投资线索）
- OpenClaw 场景优化：技能负责结构化数据，LLM 负责专业报告生成

## Data Sources (What powers this skill)

- **QVeris MCP/API**: unified tool discovery and execution (`qveris.ai`)
- **THS iFinD (CN/HK market data)**:
  - real-time quote: `ths_ifind.real_time_quotation`
  - financial statements: `ths_ifind.financial_statements`
  - company profile: `ths_ifind.company_basics`
  - historical prices: `ths_ifind.history_quotation`
- **Caidazi (CN news/research/public account)**:
  - `caidazi.news.query`
  - `caidazi.report.query`
  - `caidazi.search.hybrid.list`
  - `caidazi.search.hybrid_v2.query`
- **Global market/news sentiment**:
  - `alpha_news_sentiment`
  - `finnhub.news`
- **X/Twitter domain sentiment**:
  - `qveris_social.x_domain_hot_topics`
  - `qveris_social.x_domain_hot_events`
  - `qveris_social.x_domain_new_posts`
  - `x_developer.2.tweets.search.recent`

## Highlights

- Full-stack market intelligence: quote, fundamentals, technicals, news sentiment, and X sentiment
- Deterministic routing with `references/tool-chains.json` (stable tool priority by market/capability)
- Evolution v2: parameter-template memory from successful calls (reduces parameter mismatch errors)
- Cross-market support for US/HK/CN with market-aware symbol normalization
- Company-name friendly flow: auto resolves common company names to ticker + market (e.g. `特变电工` -> `600089.SH`)
- CN/HK sentiment enhanced with `caidazi` channels (research reports, news, WeChat/public accounts)
- CN/HK fundamentals enhanced with THS financial statements (income/balance sheet/cash flow)
- Data quality guardrails: completeness, freshness, and cross-source consistency checks
- LLM-ready structured outputs: skill code focuses on data, OpenClaw focuses on final narrative
- Structured thesis output: drivers, risks, bull/base/bear scenarios, tracking KPIs
- Event radar from latest news + X, with timeline/theme view and cross-source topic evidence
- Structured outputs for both human workflows (`markdown`) and downstream systems (`json`)

## Why It Stands Out

- **Reliable in noisy API environments**: auto-fallback when a provider degrades or rejects symbols.
- **Stable but adaptive**: hardcoded tool-chain for predictability + evolution parameter templates for robustness.
- **Signal fusion, not single-source bias**: combines market data and sentiment channels.
- **Security-minded by default**: avoids key leakage and keeps evolution state metadata-only.
- **Readable by default, evidence on demand**: standard report is concise; add `--evidence` for full parsed/raw trace.

## Requirements

- Node.js 18+
- `QVERIS_API_KEY`

```bash
export QVERIS_API_KEY="your-api-key"
```

## Install as an Independent Skill

```bash
npx skills add <repo-url> --skill stock-copilot-pro
```

Or copy this folder directly into your agent skill directory.

## What You Get

- One-symbol analysis with confidence and risk notes
- Multi-symbol comparison for portfolio-level decisions
- Watchlist management for holdings and watch symbols (`watch list/add/remove`)
- Scheduled daily briefs (`brief --type morning|evening`) for OpenClaw cron jobs
- Industry hot-topic radar (`radar`) with candidate idea mapping
- Unified OpenClaw flow:
  - Skill commands return structured data (`analyze`/`brief`/`radar`)
  - `SKILL.md` provides professional analysis guides for each module
  - OpenClaw LLM generates concise, actionable final reports

## Usage

### Analyze one symbol

```bash
node scripts/stock_copilot_pro.mjs analyze --symbol AAPL --market US --mode comprehensive
```

### Public-audience preference flow

Without preference flags, the script returns a questionnaire by default.

```bash
node scripts/stock_copilot_pro.mjs analyze --symbol AAPL
```

Skip questionnaire and run directly:

```bash
node scripts/stock_copilot_pro.mjs analyze --symbol AAPL --skip-questionnaire --style balanced --risk mid --horizon mid
```

### Analyze HK/CN symbols

```bash
node scripts/stock_copilot_pro.mjs analyze --symbol 0700 --market HK --mode technical
node scripts/stock_copilot_pro.mjs analyze --symbol 600519 --market CN --mode comprehensive
node scripts/stock_copilot_pro.mjs analyze --symbol "特变电工" --mode comprehensive
```

### Compare multiple symbols

```bash
node scripts/stock_copilot_pro.mjs compare --symbols AAPL,MSFT,NVDA --market GLOBAL --mode comprehensive
```

### Watchlist management

```bash
node scripts/stock_copilot_pro.mjs watch --action list
node scripts/stock_copilot_pro.mjs watch --action add --bucket holdings --symbol AAPL --market US
node scripts/stock_copilot_pro.mjs watch --action add --bucket watchlist --symbol 0700.HK --market HK
node scripts/stock_copilot_pro.mjs watch --action remove --bucket watchlist --symbol 0700.HK --market HK
```

### Morning/Evening brief

```bash
node scripts/stock_copilot_pro.mjs brief --type morning --format markdown
node scripts/stock_copilot_pro.mjs brief --type evening --format chat
```

### Industry radar

```bash
node scripts/stock_copilot_pro.mjs radar --market GLOBAL --limit 10
```

### JSON output

```bash
node scripts/stock_copilot_pro.mjs analyze --symbol AAPL --format json --skip-questionnaire
```

### Chat output

```bash
node scripts/stock_copilot_pro.mjs analyze --symbol AAPL --format chat --skip-questionnaire
```

### Event radar options

```bash
node scripts/stock_copilot_pro.mjs analyze --symbol NVDA --skip-questionnaire --event-view theme --event-window-days 14 --event-universe global
```

### Routing and evolution behavior

- Tool selection priority comes from `references/tool-chains.json`
- If preferred tools fail, script falls back to generic capability search
- Evolution state file: `.evolution/tool-evolution.json` (v2 schema)
- Evolution stores successful parameter templates (`param_templates`) and examples (`sample_successful_params`)
- Evolution does **not** control tool priority ranking
- No API key/auth header/raw payload are persisted
- You can disable persistence per run with `--no-evolution`

## Prompt Examples

- `Give me a comprehensive report for AAPL`
- `Analyze 0700.HK with technical focus`
- `Compare AAPL, MSFT, NVDA and rank by risk-reward`
- `Analyze 600519 with fundamentals and sentiment`
- `Give me AAPL analysis with X sentiment included`
- `Compare 0700.HK and 600519 across fundamentals and sentiment`

## Options

- `--market`: `US|HK|CN|GLOBAL` (default: `GLOBAL`)
- `--mode`: `basic|fundamental|technical|comprehensive` (default: `comprehensive`)
- `--format`: `markdown|json|chat` (default: `markdown`)
- `--action`: `list|add|remove` (for `watch` command)
- `--bucket`: `holdings|watchlist` (for `watch add/remove`)
- `--type`: `morning|evening` (for `brief` command)
- `--max-items`: max symbols included in brief (default: `8`)
- `--limit`: search result count per capability (default: `10`)
- `--max-size`: max response bytes per execution (default: `30000`)
- `--timeout`: timeout in seconds (default: `25`)
- `--include-source-urls`: include provider `full_content_file_url` in output (off by default)
- `--evidence`: include full parsed/raw evidence sections (off by default)
- `--no-evolution`: disable reading/writing `.evolution/tool-evolution.json` for this run
- `--horizon`: `short|mid|long`
- `--risk`: `low|mid|high`
- `--style`: `value|balanced|growth|trading`
- `--actionable`: include execution-oriented strategy rules
- `--skip-questionnaire`: skip default preference questionnaire
- `--summary-only`: compact summary-first markdown output
- `--event-window-days`: event window size (7/14/30)
- `--event-universe`: `global|same_market`
- `--event-view`: `timeline|theme`

## Notes

- Data availability can vary by symbol and provider.
- The script will attempt fallback tools and clearly report missing sections.
- External source URLs are hidden by default in report output.
- `full_content_file_url` is supported for complete payload retrieval, but runtime restricts fetch host to `qveris.ai` (HTTPS only).
- X sentiment may use direct ticker search first and fall back to finance-domain hot posts when needed.
- Report includes system-time data cutoff and transparent routing/template-hit metadata.
- Watchlist local file path: `config/watchlist.json` (example: `config/watchlist.example.json`).
- OpenClaw scheduler example: `config/openclaw-cron.example.json`.

## Containerized local development

Development and OpenClaw integration files are managed at repository root under
`dev-infra/` to keep this skill folder publish-clean for ClawHub.

- Start dev container: `make up`
- Check runtime: `make check`
- Run regression smoke: `make smoke`
- Stop containers: `make down`

OpenClaw session prompt fixtures are in
`dev-infra/stock-copilot-pro/prompts/` (not in skill package).

## Troubleshooting

- `QVERIS_API_KEY environment variable is required`
  - Export the key first, then rerun.
- Some symbols return sparse/empty fields
  - Retry with market-specific code format (`0700.HK`, `600519.SH`).
  - For CN/HK fundamentals, the script prioritizes THS financial statements and always calls `company_basics` to补齐公司画像字段。
  - For some HK symbols, income/cash-flow fields may still be empty due to upstream coverage; report will explicitly标注数据源空缺。
  - Use `--mode basic` to get a quick quote/fundamentals baseline.
- Sentiment endpoint rejects ticker format
  - Script falls back to general market news and adds warnings in `risks`.
- X query result quality is noisy
  - Use stricter queries or combine with `fundamental` mode for better filtering.
- Rate-limited providers
  - Wait 30-60 seconds and retry, or reduce request burst.

## Live Validation Snapshot

Validated via QVeris MCP tool executions:

- `US (AAPL)`:
  - Quote: success
  - Fundamentals: success
  - Technicals: success
  - Sentiment: success
  - X sentiment: success
- `HK (0700.HK)`:
  - Quote: success (THS real-time quotation)
  - Fundamentals: success (THS financial statements / company basics fallback)
  - Technical trend: success (THS history quotation)
  - Sentiment: fallback when ticker format is rejected
  - X sentiment: success (cached and direct query path verified)
- `CN (600519.SH/600519.SS)`:
  - Quote: success (THS real-time quotation)
  - Fundamentals: success (THS financial statements / company basics fallback)
  - Technical trend: success (THS history quotation / RSI where available)
  - Sentiment: fallback when ticker format is rejected
  - X sentiment: success (direct or fallback path verified)
- `CN company-name input (特变电工)`:
  - Input resolution: success (`特变电工` -> `600089.SH`)
  - News: success (`caidazi.news.query`)
  - Research reports: success (`caidazi.report.query`)
  - Fundamentals: success (`ths_ifind.financial_statements`, income statement fields verified)

## Security

- Never hardcode `QVERIS_API_KEY` in committed files.
- `.env.local` is supported for local testing but should not be uploaded.
- Runtime evolution state stores tool metadata and parameter templates only.
- API keys and authorization headers are not persisted in evolution files.
- Evolution state is pruned with bounded size to avoid unbounded local persistence.
- Script calls only QVeris APIs (`qveris.ai`) and does not install packages or run arbitrary commands.
- Even when provider payload includes `full_content_file_url`, fetch is host-restricted to `qveris.ai` to prevent arbitrary outbound requests.

## Disclaimer

This skill is for research and educational purposes only.
It does not constitute financial or investment advice.



path: config/openclaw-cron.example.json
content: {
  "_comment": "OpenClaw cron.add payload examples. Do not write into ~/.openclaw/cron/jobs.json. Create each job via CLI (openclaw cron add) or Gateway cron tool; set delivery.channel and delivery.to for your channel (e.g. feishu).",
  "jobs": [
    {
      "name": "Stock morning brief",
      "schedule": { "kind": "cron", "expr": "0 9 * * 1-5", "tz": "Asia/Shanghai" },
      "sessionTarget": "isolated",
      "wakeMode": "now",
      "payload": {
        "kind": "agentTurn",
        "message": "Use stock-copilot-pro to generate morning brief: run brief --type morning --max-items 8 --format chat and give an actionable plan for today."
      },
      "delivery": { "mode": "announce", "bestEffort": true }
    },
    {
      "name": "Stock evening brief",
      "schedule": { "kind": "cron", "expr": "0 17 * * 1-5", "tz": "Asia/Shanghai" },
      "sessionTarget": "isolated",
      "wakeMode": "now",
      "payload": {
        "kind": "agentTurn",
        "message": "Use stock-copilot-pro to generate evening brief: run brief --type evening --max-items 8 --format chat and give tomorrow's plan and thesis check."
      },
      "delivery": { "mode": "announce", "bestEffort": true }
    },
    {
      "name": "Stock daily radar",
      "schedule": { "kind": "cron", "expr": "30 8 * * 1-5", "tz": "Asia/Shanghai" },
      "sessionTarget": "isolated",
      "wakeMode": "now",
      "payload": {
        "kind": "agentTurn",
        "message": "Use stock-copilot-pro to run industry radar: run radar --market GLOBAL --limit 10 --format markdown and summarize 3–5 investable themes with recommendations."
      },
      "delivery": { "mode": "none" }
    }
  ]
}


path: config/watchlist.example.json
content: {
  "updatedAt": "2026-02-16T09:00:00.000Z",
  "holdings": [
    {
      "symbol": "AAPL",
      "market": "US",
      "note": "核心持仓"
    },
    {
      "symbol": "600519.SH",
      "market": "CN",
      "note": "白酒龙头"
    }
  ],
  "watchlist": [
    {
      "symbol": "0700.HK",
      "market": "HK"
    },
    {
      "symbol": "NVDA",
      "market": "US"
    }
  ]
}


path: config/watchlist.json
content: {
  "updatedAt": "2026-02-22T00:00:00.000Z",
  "holdings": [],
  "watchlist": []
}

path: references/metrics-and-signals.md
content: # Metrics and Signals

## Quick Technical Thresholds

- RSI > 70: overbought risk
- RSI < 30: oversold rebound zone
- RSI 45-55: neutral momentum area

- MACD above signal: bullish momentum bias
- MACD below signal: bearish momentum bias
- Near-zero MACD with flat slope: weak trend

## Fundamental Snapshot Focus

- Valuation: `PERatio`, `ForwardPE`, `PriceToSalesRatioTTM`, `PriceToBookRatio`
- Profitability: `ProfitMargin`, `OperatingMarginTTM`, `ReturnOnEquityTTM`
- Growth: `QuarterlyRevenueGrowthYOY`, `QuarterlyEarningsGrowthYOY`
- Balance sheet context: debt/liquidity fields if available

## Sentiment Interpretation

- Use both article-level and ticker-level sentiment where available
- Treat sentiment as short-horizon context, not standalone thesis
- If sentiment conflicts with price trend, lower confidence and flag explicitly

## Confidence Heuristic

- High: 3+ domains available and consistent, data freshness acceptable
- Medium: 2-3 domains available or minor conflicts/gaps
- Low: severe missing fields, stale data, or conflicting core signals



path: references/report-template.md
content: # Report Template

## summary

- Symbol / market
- Last price context (if available)
- Confidence score (`high|medium|low`)
- Main takeaway in 2-3 bullets

## fundamentals

- Company overview snapshot
- Key valuation and profitability metrics
- Growth and balance-sheet highlights
- Missing fields or stale data warnings

## technicals

- Latest RSI and interpretation
- MACD / moving-average directional note (if present)
- Trend bias (`bullish|neutral|bearish`)
- Signal conflicts and limitations

## sentiment

- News sample size and freshness window
- Overall sentiment distribution
- Ticker-level sentiment highlights
- Potential event-driven risks

## risks

- Data quality risks (missing/inconsistent/outdated)
- Market and liquidity considerations
- Model/tooling limitations

## conclusion

- Consolidated view (bull/base/bear leaning)
- What to monitor next (3 bullets max)
- Final disclaimer (not investment advice)



path: references/sector-benchmarks.json
content: {
  "default": {
    "pe_median": 20,
    "pb_median": 2.5,
    "roe_benchmark": 12,
    "profit_margin_benchmark": 0.1,
    "debt_ratio_warn": 0.6
  },
  "electrical_equipment": {
    "pe_median": 18,
    "pb_median": 1.8,
    "roe_benchmark": 10,
    "profit_margin_benchmark": 0.08,
    "debt_ratio_warn": 0.62
  },
  "semiconductor": {
    "pe_median": 35,
    "pb_median": 5.0,
    "roe_benchmark": 15,
    "profit_margin_benchmark": 0.18,
    "debt_ratio_warn": 0.5
  },
  "banking": {
    "pe_median": 6,
    "pb_median": 0.6,
    "roe_benchmark": 10,
    "profit_margin_benchmark": 0.25,
    "debt_ratio_warn": 0.9
  },
  "new_energy": {
    "pe_median": 24,
    "pb_median": 2.6,
    "roe_benchmark": 11,
    "profit_margin_benchmark": 0.09,
    "debt_ratio_warn": 0.58
  }
}


path: references/tool-chains.json
content: {
  "quote": {
    "CN": [
      "ths_ifind.real_time_quotation"
    ],
    "HK": [
      "ths_ifind.real_time_quotation"
    ],
    "US": [
      "finnhub_io_api.stock.quote",
      "twelvedata.quote.retrieve",
      "alphavantage.time_series.intraday"
    ],
    "GLOBAL": [
      "finnhub_io_api.stock.quote",
      "alphavantage.time_series.intraday",
      "twelvedata.quote.retrieve"
    ]
  },
  "fundamentals": {
    "CN": [
      "ths_ifind.financial_statements",
      "ths_ifind.income_statement",
      "ths_ifind.balance_sheet",
      "ths_ifind.cash_flow_statement",
      "ths_ifind.company_basics"
    ],
    "HK": [
      "ths_ifind.financial_statements",
      "ths_ifind.company_basics"
    ],
    "US": [
      "alphavantage.fundamentals.overview",
      "alphavantage.fundamentals.income_statement",
      "finnhub_io_api.stock.metric"
    ],
    "GLOBAL": [
      "alphavantage.fundamentals.overview",
      "alphavantage.fundamentals.income_statement",
      "finnhub_io_api.stock.metric"
    ]
  },
  "technicals": {
    "CN": [
      "ths_ifind.history_quotation"
    ],
    "HK": [
      "ths_ifind.history_quotation"
    ],
    "US": [
      "alphavantage.technical_indicators.rsi",
      "alphavantage.technical_indicators.macd",
      "twelvedata.rsi.retrieve"
    ],
    "GLOBAL": [
      "alphavantage.technical_indicators.rsi",
      "alphavantage.technical_indicators.macd"
    ]
  },
  "sentiment": {
    "CN": [
      "caidazi.search.hybrid_v2.query",
      "caidazi.news.query",
      "caidazi.report.query",
      "finnhub.news.retrieve"
    ],
    "HK": [
      "caidazi.search.hybrid_v2.query",
      "caidazi.news.query",
      "caidazi.report.query",
      "finnhub.news.retrieve"
    ],
    "US": [
      "alphavantage.news_sentiment.query",
      "finnhub.news.retrieve"
    ],
    "GLOBAL": [
      "alphavantage.news_sentiment.query",
      "finnhub.news.retrieve"
    ]
  },
  "x_sentiment": {
    "ALL": [
      "x_developer.2.tweets.search.recent",
      "qveris_social.x_domain_hot_topics",
      "qveris_social.x_domain_new_posts",
      "qveris_social.x_domain_hot_events"
    ]
  }
}


path: references/tool-selection.md
content: # Tool Selection Strategy

## Capability Buckets

- `quote`: real-time quote / OHLCV
- `fundamentals`: company overview / key metrics
- `technicals`: RSI / MACD / moving averages
- `sentiment`: market news and sentiment
- `x_sentiment`: X/Twitter ticker search and finance-domain hot topics

## Ranking Rule

For each bucket, rank candidate tools by:

1. Higher `success_rate`
2. Lower `avg_execution_time_ms`
3. Better parameter fit (supports required fields for target symbol)

Use top 1 as primary and next 2 as fallbacks.

## Market-Aware Symbol Fallback

When direct symbol fails, try variants:

- `US`: `AAPL`, then `AAPL.US`
- `HK`: `0700.HK`, then `700.HK`, then `0700`
- `CN`: `600519.SS`, `600519.SH`, then `600519`
- `GLOBAL`: run direct first, then market-specific suffixes if known

For CN/HK markets, prefer market-native providers when available:

- Quote / fundamentals: `ths_ifind.real_time_quotation.v1`, `ths_ifind.company_basics.v1`
- Technical trend proxy: `ths_ifind.history_quotation.v1`
- Sentiment fallback: generic market news when ticker-restricted APIs reject symbol format

## Reliability Gate

Prefer tools with:

- `success_rate >= 0.7` for primary use
- `avg_execution_time_ms > 0` and not abnormally high for interactive usage

If no candidate passes gate, still execute the best available one and mark confidence lower.

## Evolution Priority Queue

- Before live search, try tools from `.evolution/tool-evolution.json` priority queue.
- Queue admission (aggressive mode): one successful execution can enroll the tool.
- If queue candidates fail, fall back to fresh search and normal ranking.
- Queue ordering is based on recent success ratio, speed, and market match.
- Never store credentials or raw sensitive payload in evolution state.

For `x_sentiment`, only X-related tools are eligible in routing:

- `x_developer.2.tweets.search.recent.*` (primary)
- `qveris_social.x_domain_new_posts_v1` (fallback)
- `qveris_social.x_domain_hot_topics_v1` (fallback)
- `qveris_social.x_domain_hot_events_v1` (fallback)

## Large Payload Handling

If result returns truncation metadata:

- Keep `truncated_content` for summary extraction
- Preserve `full_content_file_url` in report notes
- Avoid dumping full raw payload into markdown output



path: scripts/lib/analysis.mjs
content: import { clampScore, gradeFromScore, toNumber } from "./utils.mjs";
import { pickSectorBenchmarks } from "./data.mjs";

export function buildScorecard(payload, benchmarks, market) {
  const f = payload.fundamentals?.parsed || {};
  const t = payload.technicals?.parsed || {};
  const s = payload.sentiment?.parsed || {};
  const x = payload.x_sentiment?.parsed || {};
  const b = pickSectorBenchmarks(benchmarks, market);

  let valuation = 50;
  if (f.pe != null && b.pe_median) {
    const ratio = f.pe / b.pe_median;
    if (ratio <= 0.7) valuation += 25;
    else if (ratio <= 0.9) valuation += 15;
    else if (ratio <= 1.1) valuation += 5;
    else if (ratio >= 1.5) valuation -= 20;
    else valuation -= 8;
  }
  if (f.pb != null && b.pb_median) {
    const ratio = f.pb / b.pb_median;
    if (ratio <= 0.7) valuation += 18;
    else if (ratio <= 0.9) valuation += 10;
    else if (ratio <= 1.1) valuation += 3;
    else if (ratio >= 1.6) valuation -= 18;
    else valuation -= 8;
  }

  let quality = 50;
  const margin = f.profitMargin != null ? f.profitMargin : f.revenue && f.netProfit ? f.netProfit / f.revenue : null;
  if (margin != null && b.profit_margin_benchmark != null) quality += margin >= b.profit_margin_benchmark ? 12 : -8;
  if (f.totalAssets != null && f.totalLiabilities != null && f.totalAssets > 0) {
    const debtRatio = f.totalLiabilities / f.totalAssets;
    quality += debtRatio <= (b.debt_ratio_warn ?? 0.6) ? 12 : -10;
  }
  if (f.operatingCashflow != null) quality += f.operatingCashflow > 0 ? 15 : -12;

  let growth = 50;
  if (f.revenueGrowthYoy != null) {
    if (f.revenueGrowthYoy >= 20) growth += 22;
    else if (f.revenueGrowthYoy >= 10) growth += 14;
    else if (f.revenueGrowthYoy >= 0) growth += 6;
    else growth -= 12;
  }
  if (f.earningsGrowthYoy != null) growth += f.earningsGrowthYoy >= 0 ? 8 : -8;

  let technicals = 50;
  if (t.rsi != null) {
    if (t.rsi >= 40 && t.rsi <= 65) technicals += 12;
    else if (t.rsi > 70) technicals -= 8;
    else if (t.rsi < 30) technicals += 5;
  }
  if (t.changeRatio != null) technicals += t.changeRatio > 0 ? 6 : -4;
  if (t.change5d != null) technicals += t.change5d > 0 ? 8 : -6;

  let sentiment = 50;
  if (s.itemCount != null) sentiment += Math.min(10, s.itemCount * 2);
  if (s.latestTickerSentiment != null && typeof s.latestTickerSentiment === "number") sentiment += s.latestTickerSentiment > 0 ? 8 : -6;
  if (x.itemCount != null) sentiment += Math.min(8, x.itemCount);

  const weights = { valuation: 0.3, quality: 0.25, growth: 0.2, technicals: 0.15, sentiment: 0.1 };
  const score = {
    valuation: clampScore(valuation),
    quality: clampScore(quality),
    growth: clampScore(growth),
    technicals: clampScore(technicals),
    sentiment: clampScore(sentiment),
  };
  const composite = clampScore(
    score.valuation * weights.valuation +
      score.quality * weights.quality +
      score.growth * weights.growth +
      score.technicals * weights.technicals +
      score.sentiment * weights.sentiment,
  );
  return { weights, score, composite, grade: gradeFromScore(composite) };
}

export function buildFinancialQuality(payload, benchmarks, market) {
  const f = payload.fundamentals?.parsed || {};
  const b = pickSectorBenchmarks(benchmarks, market);
  const debtRatio = f.totalAssets > 0 ? toNumber(f.totalLiabilities) / toNumber(f.totalAssets) : null;
  const margin = f.profitMargin != null ? f.profitMargin : f.revenue && f.netProfit ? f.netProfit / f.revenue : null;
  const ocfConsistency =
    f.operatingCashflow != null && f.netProfit != null
      ? f.operatingCashflow >= 0 && f.netProfit >= 0
        ? "strong"
        : f.operatingCashflow < 0 && f.netProfit > 0
          ? "weak"
          : "mixed"
      : "unknown";
  return {
    debtRatio,
    debtRisk:
      debtRatio == null ? "unknown" : debtRatio > (b.debt_ratio_warn ?? 0.6) ? "high" : debtRatio > (b.debt_ratio_warn ?? 0.6) * 0.85 ? "medium" : "low",
    profitMargin: margin,
    marginVsBenchmark: margin == null || b.profit_margin_benchmark == null ? "unknown" : margin >= b.profit_margin_benchmark ? "above" : "below",
    cashflowConsistency: ocfConsistency,
  };
}

export function buildValuationFrame(payload, scorecard, benchmarks, market) {
  const q = payload.quote?.parsed || {};
  const f = payload.fundamentals?.parsed || {};
  const b = pickSectorBenchmarks(benchmarks, market);

  const peRatio = f.pe != null && b.pe_median ? f.pe / b.pe_median : null;
  const pbRatio = f.pb != null && b.pb_median ? f.pb / b.pb_median : null;
  const verdictScore = scorecard?.score?.valuation ?? 50;
  const verdict =
    verdictScore >= 75
      ? "undervalued"
      : verdictScore >= 58
        ? "fair"
        : verdictScore >= 45
          ? "slightly_overvalued"
          : "overvalued";

  const currentPrice = q.price;
  let conservativeValue = null;
  if (currentPrice != null && f.pe != null && f.pe > 0 && b.pe_median) {
    conservativeValue = currentPrice * (b.pe_median / f.pe) * 0.8;
  }
  const marginOfSafety =
    conservativeValue && conservativeValue > 0 ? ((conservativeValue - currentPrice) / conservativeValue) * 100 : null;

  return {
    relative: {
      pe: { current: f.pe ?? null, industryMedian: b.pe_median ?? null, ratioToMedian: peRatio },
      pb: { current: f.pb ?? null, industryMedian: b.pb_median ?? null, ratioToMedian: pbRatio },
      ps: { current: f.ps ?? null, industryMedian: null, ratioToMedian: null },
    },
    intrinsic: {
      currentPrice: currentPrice ?? null,
      conservativeValue,
      marginOfSafety,
    },
    verdict,
    confidence: scorecard?.composite >= 70 ? "high" : scorecard?.composite >= 55 ? "medium" : "low",
  };
}

export function detectChaseRisk(payload) {
  const q = payload.quote?.parsed || {};
  const t = payload.technicals?.parsed || {};
  const s = payload.sentiment?.parsed || {};
  const change5d = t.change5d;
  const change1d = q.percentChange;
  const volumeRatio = t.volumeRatio;
  let classification = "事件驱动型";
  let signal = "可买入";
  let risk = "low";
  if ((change5d != null && change5d >= 10) || (change1d != null && change1d >= 5) || (volumeRatio != null && volumeRatio >= 1.8)) {
    classification = "交易回踩型";
    signal = "需谨慎/回避";
    risk = "high";
  } else if ((change5d != null && change5d >= 5) || (change1d != null && change1d >= 3) || (volumeRatio != null && volumeRatio >= 1.5)) {
    classification = "交易回踩型-轻度";
    signal = "需谨慎，可关注回踩机会";
    risk = "medium";
  }
  const hasEvent = (s.itemCount || 0) > 0;
  return { classification, signal, risk, hasEvent, metrics: { change5d, change1d, volumeRatio, newsCount: s.itemCount ?? 0 } };
}

export function buildThesis(payload, market, scorecard, valuationFrame, financialQuality, eventRadar) {
  const f = payload.fundamentals?.parsed || {};
  const t = payload.technicals?.parsed || {};
  const keyDrivers = [];
  const keyRisks = [];
  if (f.revenueGrowthYoy != null) {
    keyDrivers.push({
      factor: "营收增长动能",
      evidence: `营收同比 ${f.revenueGrowthYoy}`,
      trackingKPI: "季度营收同比增速",
      invalidationCondition: "营收同比连续两个季度转负",
    });
  }
  if (t.change5d != null && t.change5d > 0) {
    keyDrivers.push({
      factor: "短期趋势改善",
      evidence: `近5日涨跌 ${t.change5d.toFixed(2)}%`,
      trackingKPI: "20日趋势与成交量配合",
      invalidationCondition: "趋势破位且放量下跌",
    });
  }
  if (financialQuality.debtRisk === "high") {
    keyRisks.push({
      category: "financial",
      description: "资产负债结构承压，杠杆风险偏高",
      severity: "high",
      mitigationPossible: false,
    });
  }
  if (valuationFrame.verdict.includes("overvalued")) {
    keyRisks.push({
      category: "valuation",
      description: "估值偏高，安全边际不足",
      severity: "medium",
      mitigationPossible: true,
    });
  }
  if ((eventRadar?.events || []).length > 0) {
    keyRisks.push({
      category: "event",
      description: "事件驱动波动增强，消息面变化可能快速影响价格",
      severity: "medium",
      mitigationPossible: true,
    });
  }
  const composite = scorecard?.composite ?? 50;
  return {
    market,
    businessSummary: {
      industry: f.industry || "N/A",
      mainBusiness: f.mainBusiness || "N/A",
      tags: f.tags || [],
    },
    keyDrivers,
    keyRisks,
    scenarios: {
      bull: {
        condition: "盈利增速修复+估值扩张",
        probability: composite >= 70 ? "medium-high" : "medium",
      },
      base: {
        condition: "盈利稳定+估值中枢维持",
        probability: "high",
      },
      bear: {
        condition: "业绩不及预期或宏观流动性收紧",
        probability: composite < 55 ? "medium-high" : "medium",
      },
    },
    trackingKPIs: [
      "营收同比",
      "净利润同比",
      "经营现金流",
      "PE/PB 相对行业",
      "趋势强弱与量能",
      "重点事件进展",
    ],
  };
}



path: scripts/lib/config/defaults.mjs
content: export const DEFAULT_WATCHLIST = {
  updatedAt: null,
  holdings: [],
  watchlist: [],
};

export const DEFAULT_BRIEF_OPTIONS = {
  mode: "basic",
  maxItems: 8,
};


path: scripts/lib/config/loader.mjs
content: import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SKILL_ROOT = path.resolve(__dirname, "../../..");

export function resolveSkillPath(...segments) {
  return path.join(SKILL_ROOT, ...segments);
}

export async function readJsonOrDefault(filePath, fallbackValue) {
  try {
    const content = await fs.readFile(filePath, "utf8");
    return JSON.parse(content);
  } catch {
    return fallbackValue;
  }
}

export async function writeJson(filePath, value) {
  await fs.mkdir(path.dirname(filePath), { recursive: true });
  await fs.writeFile(filePath, JSON.stringify(value, null, 2), "utf8");
}


path: scripts/lib/config/watchlist.mjs
content: import { DEFAULT_WATCHLIST } from "./defaults.mjs";
import { readJsonOrDefault, resolveSkillPath, writeJson } from "./loader.mjs";

export function getWatchlistPath() {
  return resolveSkillPath("config", "watchlist.json");
}

function normalizeItem(item, defaultMarket = "GLOBAL") {
  if (typeof item === "string") {
    return { symbol: item, market: defaultMarket };
  }
  return {
    symbol: String(item?.symbol || "").trim(),
    market: String(item?.market || defaultMarket).toUpperCase(),
    note: item?.note || null,
  };
}

function dedupe(items = []) {
  const seen = new Set();
  const out = [];
  for (const item of items) {
    const normalized = normalizeItem(item);
    if (!normalized.symbol) continue;
    const key = `${normalized.symbol.toUpperCase()}#${normalized.market}`;
    if (seen.has(key)) continue;
    seen.add(key);
    out.push(normalized);
  }
  return out;
}

export async function loadWatchlist() {
  const data = await readJsonOrDefault(getWatchlistPath(), DEFAULT_WATCHLIST);
  return {
    ...DEFAULT_WATCHLIST,
    ...data,
    holdings: dedupe(data?.holdings || []),
    watchlist: dedupe(data?.watchlist || []),
  };
}

export async function saveWatchlist(value) {
  const normalized = {
    ...DEFAULT_WATCHLIST,
    ...value,
    updatedAt: new Date().toISOString(),
    holdings: dedupe(value?.holdings || []),
    watchlist: dedupe(value?.watchlist || []),
  };
  await writeJson(getWatchlistPath(), normalized);
  return normalized;
}

export async function addWatchSymbol(symbol, market = "GLOBAL", bucket = "watchlist") {
  const state = await loadWatchlist();
  const target = bucket === "holdings" ? "holdings" : "watchlist";
  state[target] = [...state[target], normalizeItem({ symbol, market })];
  return saveWatchlist(state);
}

export async function removeWatchSymbol(symbol, market = "GLOBAL", bucket = "watchlist") {
  const state = await loadWatchlist();
  const target = bucket === "holdings" ? "holdings" : "watchlist";
  const normalizedSymbol = String(symbol || "").trim().toUpperCase();
  const normalizedMarket = String(market || "GLOBAL").toUpperCase();
  state[target] = state[target].filter(
    (item) => !(item.symbol.toUpperCase() === normalizedSymbol && item.market.toUpperCase() === normalizedMarket),
  );
  return saveWatchlist(state);
}


path: scripts/lib/core/commands/brief.mjs
content: import { loadWatchlist } from "../../config/watchlist.mjs";
import { radarCommand } from "./radar.mjs";

function flattenSymbols(watchlist, maxItems = 8) {
  const all = [...(watchlist.holdings || []), ...(watchlist.watchlist || [])];
  const seen = new Set();
  const out = [];
  for (const item of all) {
    const key = `${item.symbol}#${item.market}`;
    if (seen.has(key)) continue;
    seen.add(key);
    out.push(item);
    if (out.length >= maxItems) break;
  }
  return out;
}

function indexUniverseForMarket(market = "GLOBAL") {
  const m = String(market || "GLOBAL").toUpperCase();
  if (m === "CN") {
    return [
      { symbol: "000001.SS", market: "CN", name: "上证指数" },
      { symbol: "399001.SZ", market: "CN", name: "深证成指" },
    ];
  }
  if (m === "HK") {
    return [
      { symbol: "HSI", market: "HK", name: "恒生指数" },
      { symbol: "HSCEI", market: "HK", name: "国企指数" },
    ];
  }
  if (m === "US") {
    return [
      { symbol: "SPY", market: "US", name: "标普500代理" },
      { symbol: "QQQ", market: "US", name: "纳斯达克100代理" },
    ];
  }
  return [
    { symbol: "SPY", market: "US", name: "标普500代理" },
    { symbol: "QQQ", market: "US", name: "纳斯达克100代理" },
    { symbol: "0700.HK", market: "HK", name: "港股风向代理" },
  ];
}

async function buildMarketOverview(args, analyzeSymbol) {
  const indices = indexUniverseForMarket(args.market);
  const rows = [];
  for (const idx of indices) {
    try {
      const result = await analyzeSymbol(idx.symbol, {
        ...args,
        market: idx.market,
        mode: "basic",
        skipQuestionnaire: true,
        summaryOnly: true,
      });
      const quote = result?.data?.quote?.parsed || {};
      rows.push({
        name: idx.name,
        symbol: result?.symbol || idx.symbol,
        market: result?.market || idx.market,
        price: quote.price ?? null,
        percentChange: quote.percentChange ?? null,
        timestamp: quote.timestamp ?? null,
      });
    } catch (error) {
      rows.push({
        name: idx.name,
        symbol: idx.symbol,
        market: idx.market,
        error: error?.message || String(error),
      });
    }
  }
  const valid = rows.filter((x) => !x.error && x.price != null);
  const up = valid.filter((x) => Number(x.percentChange) > 0).length;
  const down = valid.filter((x) => Number(x.percentChange) < 0).length;
  return {
    indices: rows,
    sentiment:
      valid.length === 0
        ? "unknown"
        : up >= down
          ? "risk_on"
          : "risk_off",
  };
}

function summarizeBriefDataSources(radarMeta, reports) {
  const rows = [];
  const sourceStats = radarMeta?.sourceStats || [];
  for (const stat of sourceStats) {
    if (!stat?.ok || !stat?.toolId) continue;
    rows.push({
      scope: "radar",
      source: stat.source || "unknown",
      toolId: stat.toolId,
    });
  }
  for (const report of reports || []) {
    for (const ds of report?.dataSources || []) {
      if (!ds?.toolId) continue;
      rows.push({
        scope: `holding:${report.symbol || "unknown"}`,
        source: ds.provider || "unknown",
        toolId: ds.toolId,
      });
    }
  }
  const uniq = new Map();
  for (const row of rows) {
    const key = `${row.scope}#${row.toolId}`;
    if (!uniq.has(key)) uniq.set(key, row);
  }
  return [...uniq.values()];
}

export async function briefCommand(args = {}, context = {}) {
  const type = String(args.briefType || args.type || "morning").toLowerCase();
  const maxItems = Math.max(1, Number(args.maxItems || 8));
  const analyzeSymbol = context.analyzeSymbol;
  if (typeof analyzeSymbol !== "function") {
    throw new Error("brief command requires analyzeSymbol context");
  }

  const state = await loadWatchlist();
  const symbols = flattenSymbols(state, maxItems);
  const reports = [];
  const marketOverview = await buildMarketOverview(args, analyzeSymbol);
  const radar = await radarCommand({
    ...args,
    market: args.market || "GLOBAL",
    limit: Math.max(5, Number(args.limit || 8)),
  });
  const highlights = (radar?.topics || []).slice(0, 5).map((item) => ({
    title: item.title || "未命名热点",
    source: item.source || "unknown",
    category: item.category || null,
    publishTime: item.publishTime || null,
    tickers: item.tickers || null,
    sentiment: item.sentiment ?? null,
  }));

  for (const item of symbols) {
    try {
      const result = await analyzeSymbol(item.symbol, {
        ...args,
        market: item.market || args.market || "GLOBAL",
        mode: "basic",
        skipQuestionnaire: true,
        summaryOnly: true,
      });
      reports.push({
        symbol: result.symbol,
        market: result.market,
        price: result?.data?.quote?.parsed?.price ?? null,
        percentChange: result?.data?.quote?.parsed?.percentChange ?? null,
        grade: result.analysis?.scorecard?.grade || "N/A",
        score: result.analysis?.scorecard?.composite ?? null,
        signal: result.analysis?.recommendation?.signal || "N/A",
        risk: result.analysis?.chaseRisk?.risk || "unknown",
        dataSources: result?.dataSources || [],
      });
    } catch (error) {
      reports.push({
        symbol: item.symbol,
        market: item.market || "GLOBAL",
        error: error?.message || String(error),
      });
    }
  }

  const dataSources = summarizeBriefDataSources(radar?.meta || null, reports);

  return {
    mode: "brief",
    type,
    generatedAt: new Date().toISOString(),
    coverage: {
      holdings: (state.holdings || []).length,
      watchlist: (state.watchlist || []).length,
      analyzed: reports.length,
    },
    marketOverview,
    highlights,
    radarMeta: radar?.meta || null,
    dataSources,
    meta: {
      note: "Analysis delegated to OpenClaw LLM via SKILL.md Daily Brief Analysis Guide",
      guide: "Daily Brief Analysis Guide",
    },
    reports,
  };
}


path: scripts/lib/core/commands/radar.mjs
content: import { executeTool, resolveToolPayload, searchTools } from "../../infra/qveris-client.mjs";

const RADAR_SOURCES = [
  {
    name: "caidazi_news",
    prefix: "caidazi.news.query",
    searchQuery: "caidazi finance news API",
    defaultParams: (limit) => ({
      input: "行业 热点 赛道 主题",
      size: Math.max(3, Math.min(10, limit)),
      sortOrder: "desc",
      highlight: "true",
      timeSensitive: true,
    }),
  },
  {
    name: "caidazi_report",
    prefix: "caidazi.report.query",
    searchQuery: "caidazi investment report query API",
    defaultParams: (limit) => ({
      input: "行业 景气度 研报",
      size: Math.max(3, Math.min(10, limit)),
      sortOrder: "desc",
      highlight: "true",
    }),
  },
  {
    name: "caidazi_wechat",
    prefix: "caidazi.search.hybrid_v2.query",
    searchQuery: "caidazi wechat hybrid search API",
    defaultParams: (limit) => ({
      input: "行业 热点 主线",
      sourceType: "wechat,news,report",
      size: Math.max(3, Math.min(10, limit)),
      sortOrder: "desc",
      highlight: "true",
      timeSensitive: true,
    }),
  },
  {
    name: "x_hot_topics",
    prefix: "qveris_social.x_domain_hot_topics",
    searchQuery: "X Finance domain hot topics API",
    defaultParams: (limit) => ({
      domains: ["Finance"],
      limit: Math.max(5, Math.min(20, limit)),
      min_engagement: 30,
    }),
  },
  {
    name: "alpha_news_sentiment",
    prefix: "alphavantage.news_sentiment.query.v1",
    searchQuery: "alpha vantage global market news sentiment API",
    defaultParams: (limit) => ({
      function: "NEWS_SENTIMENT",
      topics: "financial_markets,economy_macro,technology",
      sort: "LATEST",
      limit: Math.max(5, Math.min(50, limit)),
    }),
  },
  {
    name: "finnhub_market_news",
    prefix: "finnhub.market.news.list.v1",
    searchQuery: "finnhub market news API",
    defaultParams: () => ({
      category: "general",
    }),
  },
];

function toNumber(value) {
  const n = Number(value);
  return Number.isFinite(n) ? n : null;
}

function pickFirst(...values) {
  for (const v of values) {
    if (v == null) continue;
    if (typeof v === "string" && v.trim() === "") continue;
    return v;
  }
  return null;
}

function extractTopicArrays(content) {
  if (!content) return [];
  const arrays = [];
  if (Array.isArray(content)) arrays.push(content);
  if (Array.isArray(content?.feed)) arrays.push(content.feed);
  if (Array.isArray(content?.topics)) arrays.push(content.topics);
  if (Array.isArray(content?.data)) arrays.push(content.data);
  if (Array.isArray(content?.results)) arrays.push(content.results);
  if (Array.isArray(content?.posts)) arrays.push(content.posts);
  if (Array.isArray(content?.items)) arrays.push(content.items);
  if (Array.isArray(content?.hits)) arrays.push(content.hits);
  if (Array.isArray(content?.data?.hits)) arrays.push(content.data.hits);
  if (Array.isArray(content?.by_domain?.Finance)) arrays.push(content.by_domain.Finance);
  if (Array.isArray(content?.news)) arrays.push(content.news);
  if (Array.isArray(content?.articles)) arrays.push(content.articles);
  return arrays;
}

function extractTickers(obj) {
  const tickers = [];
  if (Array.isArray(obj?.ticker_sentiment)) {
    for (const ts of obj.ticker_sentiment) {
      if (ts?.ticker) tickers.push(ts.ticker);
    }
  }
  if (Array.isArray(obj?.tickers)) {
    for (const t of obj.tickers) {
      if (typeof t === "string") tickers.push(t);
      else if (t?.ticker) tickers.push(t.ticker);
    }
  }
  if (obj?.related && typeof obj.related === "string") {
    const parts = obj.related.split(",").map((s) => s.trim()).filter(Boolean);
    tickers.push(...parts);
  }
  const tickerMatch = String(obj?.title || obj?.headline || "").match(/\$([A-Z]{1,5})/g);
  if (tickerMatch) {
    for (const m of tickerMatch) tickers.push(m.replace("$", ""));
  }
  return [...new Set(tickers)].slice(0, 5);
}

function normalizeTopic(rawItem, sourceName) {
  const sourceObj = rawItem?.source && typeof rawItem.source === "object" ? rawItem.source : rawItem;
  const title = pickFirst(
    sourceObj?.title,
    sourceObj?.headline,
    sourceObj?.topic,
    sourceObj?.name,
    sourceObj?.summary,
    sourceObj?.banner_image ? null : sourceObj?.text,
    sourceObj?.full_text,
  );
  if (!title) return null;
  const category = pickFirst(
    sourceObj?.topics?.[0]?.topic,
    sourceObj?.category,
    sourceObj?.theme,
    sourceObj?.tag,
    sourceName === "alpha_news_sentiment" ? "global_markets" : null,
    sourceName === "finnhub_market_news" ? "market_news" : null,
    sourceName === "caidazi_report" ? "research" : null,
    sourceName === "caidazi_news" ? "cn_news" : null,
    sourceName === "caidazi_wechat" ? "cn_wechat" : null,
    sourceName === "x_hot_topics" ? "social" : null,
  );
  const publishTime = pickFirst(
    sourceObj?.time_published,
    sourceObj?.publishTime,
    sourceObj?.published_at,
    sourceObj?.datetime,
    sourceObj?.created_at,
    sourceObj?.time,
    sourceObj?.effectiveTime,
  );
  const sentiment = toNumber(
    pickFirst(
      sourceObj?.overall_sentiment_score,
      sourceObj?.sentiment_score,
      sourceObj?.sentiment,
    ),
  );
  const url = pickFirst(
    sourceObj?.url,
    sourceObj?.link,
    sourceObj?.source_url,
  );
  const tickers = extractTickers(sourceObj);
  return {
    title: String(title).trim(),
    category: category ? String(category).trim().toLowerCase().replace(/\s+/g, "_") : null,
    source: sourceName,
    publishTime: publishTime || null,
    sentiment,
    tickers: tickers.length > 0 ? tickers : null,
    url: url || null,
  };
}

const SOURCE_QUALITY_WEIGHTS = {
  alpha_news_sentiment: 100,
  finnhub_market_news: 95,
  caidazi_report: 90,
  caidazi_news: 85,
  caidazi_wechat: 80,
  x_hot_topics: 30,
  fallback_generated: 10,
};

function isLowQualityContent(title) {
  if (!title) return true;
  const t = String(title).trim();
  if (t.length < 15) return true;
  if (/^@\w+\s*[\p{Emoji}\s]*$/u.test(t)) return true;
  if (/^[\p{Emoji}\s]+$/u.test(t)) return true;
  if (/^(Yes|No|Yup|Nope|Exactly|True|False|💯|😂|👍|🔥)\s*$/i.test(t)) return true;
  if (t.startsWith("@") && t.length < 30 && !t.includes(" ")) return true;
  return false;
}

function dedupeAndSortTopics(items = [], limit = 10) {
  const seen = new Set();
  const deduped = [];
  for (const item of items) {
    if (!item || !item.title) continue;
    if (isLowQualityContent(item.title)) continue;
    const key = String(item.title).trim().toLowerCase().slice(0, 80);
    if (!key || seen.has(key)) continue;
    seen.add(key);
    deduped.push(item);
  }
  deduped.sort((a, b) => {
    const wa = SOURCE_QUALITY_WEIGHTS[a.source] ?? 50;
    const wb = SOURCE_QUALITY_WEIGHTS[b.source] ?? 50;
    if (wa !== wb) return wb - wa;
    const ta = a.publishTime ? new Date(a.publishTime).getTime() : 0;
    const tb = b.publishTime ? new Date(b.publishTime).getTime() : 0;
    return tb - ta;
  });
  return deduped.slice(0, limit);
}

// Note: Analysis (theme clustering, investment logic, suggestions) is delegated to OpenClaw's LLM
// via the Hot Topic Analysis Guide in SKILL.md. This function only outputs structured data.

async function runSource(source, args, limit) {
  const timeoutMs = args.timeoutMs || 25_000;
  const maxSize = args.maxSize || 30_000;
  try {
    const searchResult = await searchTools(source.searchQuery, 15, timeoutMs);
    const tools = Array.isArray(searchResult?.results) ? searchResult.results : [];
    const preferred = tools.find((t) => String(t?.tool_id || "").startsWith(source.prefix));
    const selected = preferred || tools[0] || null;
    if (!selected?.tool_id) {
      return {
        source: source.name,
        ok: false,
        error: "no tool found",
        toolId: null,
        searchId: searchResult?.search_id || null,
        topics: [],
      };
    }
    const params = source.defaultParams(limit);
    const raw = await executeTool(selected.tool_id, searchResult?.search_id || null, params, maxSize, timeoutMs);
    const extracted = await resolveToolPayload(raw, {
      fetchFullContent: true,
      timeoutMs,
      fullContentAllowedHosts: ["qveris.ai"],
    });
    const content = extracted?.content ?? raw?.result ?? raw?.data ?? raw ?? {};
    const arrays = extractTopicArrays(content);
    const normalized = [];
    for (const arr of arrays) {
      for (const item of arr) {
        const n = normalizeTopic(item, source.name);
        if (n) normalized.push(n);
      }
    }
    return {
      source: source.name,
      ok: normalized.length > 0,
      error: normalized.length > 0 ? null : "empty content",
      toolId: selected.tool_id,
      searchId: searchResult?.search_id || null,
      contentMode: extracted?.meta?.contentMode || "payload",
      hasTruncatedContent: Boolean(extracted?.meta?.hasTruncatedContent),
      topics: normalized,
    };
  } catch (error) {
    return {
      source: source.name,
      ok: false,
      error: error?.message || String(error),
      toolId: null,
      searchId: null,
      topics: [],
    };
  }
}

export async function radarCommand(args = {}) {
  const market = String(args.market || "GLOBAL").toUpperCase();
  const limit = Math.max(5, Number(args.limit || 10));
  const sourceResults = await Promise.all(RADAR_SOURCES.map((source) => runSource(source, args, limit)));
  const merged = sourceResults.flatMap((x) => x.topics || []);
  let topics = dedupeAndSortTopics(merged, limit);
  if (topics.length === 0) {
    topics = [
      {
        title: "全球宏观与流动性预期",
        topic: "宏观流动性",
        source: "fallback_generated",
        publishTime: new Date().toISOString(),
        engagement: null,
      },
    ];
  }
  return {
    mode: "radar",
    market,
    generatedAt: new Date().toISOString(),
    topicCount: topics.length,
    topics,
    meta: {
      sourceStats: sourceResults.map((x) => ({
        source: x.source,
        ok: x.ok,
        count: (x.topics || []).length,
        toolId: x.toolId,
        searchId: x.searchId,
        error: x.error,
      })),
      sourceCount: sourceResults.length,
      note: "Analysis delegated to OpenClaw LLM via SKILL.md Hot Topic Analysis Guide",
    },
  };
}


path: scripts/lib/core/commands/watch.mjs
content: import { addWatchSymbol, loadWatchlist, removeWatchSymbol } from "../../config/watchlist.mjs";

export async function watchCommand(args = {}) {
  const action = String(args.watchAction || args.action || "list").toLowerCase();
  if (action === "list") {
    const state = await loadWatchlist();
    return {
      mode: "watch",
      action: "list",
      watchlist: state,
    };
  }

  const symbol = String(args.symbol || "").trim();
  if (!symbol) throw new Error("watch add/remove requires --symbol");
  const market = String(args.market || "GLOBAL").toUpperCase();
  const bucket = String(args.bucket || "watchlist").toLowerCase();

  if (action === "add") {
    const state = await addWatchSymbol(symbol, market, bucket);
    return { mode: "watch", action: "add", symbol, market, bucket, watchlist: state };
  }

  if (action === "remove") {
    const state = await removeWatchSymbol(symbol, market, bucket);
    return { mode: "watch", action: "remove", symbol, market, bucket, watchlist: state };
  }

  throw new Error(`Unknown watch action: ${action}`);
}


path: scripts/lib/core/router.mjs
content: import { briefCommand } from "./commands/brief.mjs";
import { radarCommand } from "./commands/radar.mjs";
import { watchCommand } from "./commands/watch.mjs";

export const COMMANDS = {
  brief: { handler: briefCommand, description: "早晚报生成" },
  radar: { handler: radarCommand, description: "行业热点追踪" },
  watch: { handler: watchCommand, description: "自选股管理" },
};

export function hasCommand(command) {
  return Boolean(COMMANDS[String(command || "").toLowerCase()]);
}

export async function dispatchCommand(command, args = {}, context = {}) {
  const key = String(command || "").toLowerCase();
  const cmd = COMMANDS[key];
  if (!cmd) {
    throw new Error(`Unknown routed command: ${command}`);
  }
  return cmd.handler(args, context);
}


path: scripts/lib/data/fallback.mjs
content: export const DEGRADATION_STRATEGIES = {
  "HK:fundamentals": {
    action: "warn",
    message: "港股财务数据覆盖有限，部分字段可能为空",
    hideFields: ["revenue", "netProfit", "operatingCashflow"],
  },
  timeout: {
    action: "fallback",
    fallbackProvider: "next",
    maxRetries: 2,
  },
};

export function applyCapabilityDegradation(capability, market, parsed) {
  const key = `${market}:${capability}`;
  const strategy = DEGRADATION_STRATEGIES[key];
  if (!strategy || !parsed || typeof parsed !== "object") return { parsed, warnings: [] };
  if (strategy.action === "warn") {
    const next = { ...parsed };
    for (const field of strategy.hideFields || []) {
      if (field in next && next[field] == null) delete next[field];
    }
    return { parsed: next, warnings: [strategy.message] };
  }
  return { parsed, warnings: [] };
}

export function shouldRetryOnError(errorMessage) {
  const msg = String(errorMessage || "").toLowerCase();
  return msg.includes("timeout") || msg.includes("aborterror");
}


path: scripts/lib/data/fetcher.mjs
content: import { ThsIfindProvider } from "./providers/ths-ifind.mjs";
import { applyCapabilityDegradation, shouldRetryOnError } from "./fallback.mjs";

function isCapabilityUsable(parsed) {
  if (!parsed || typeof parsed !== "object") return false;
  return Object.values(parsed).some((v) => v != null && v !== "");
}

export function getProvidersForMarket(market) {
  const providers = [];
  if (market === "CN" || market === "HK") providers.push(new ThsIfindProvider());
  return providers;
}

async function fetchWithFallback(capability, symbol, market, providers, options = {}) {
  const errors = [];
  for (const provider of providers) {
    if (!provider.supportsMarket(market)) continue;
    if (!provider.capabilities.includes(capability)) continue;
    const maxRetries = Math.max(1, Number(options.maxRetries || 1));
    for (let retry = 0; retry < maxRetries; retry++) {
      try {
        const result = await provider.fetch(capability, symbol, options);
        if (result?.success && isCapabilityUsable(result.parsed)) {
          const degraded = applyCapabilityDegradation(capability, market, result.parsed);
          return {
            success: true,
            provider: provider.name,
            toolId: result.toolId,
            parsed: degraded.parsed,
            warnings: degraded.warnings,
            raw: result.raw,
          };
        }
      } catch (error) {
        const msg = error?.message || String(error);
        errors.push({ provider: provider.name, error: msg });
        if (!shouldRetryOnError(msg)) break;
      }
    }
  }
  return { success: false, errors };
}

export async function fetchCapabilities(symbol, market, capabilities, options = {}) {
  const providers = options.providers || getProvidersForMarket(market);
  const results = {};
  await Promise.allSettled(
    (capabilities || []).map(async (capability) => {
      results[capability] = await fetchWithFallback(capability, symbol, market, providers, options);
    }),
  );
  return results;
}


path: scripts/lib/data/parser.mjs
content: import { resolveToolPayloadSync } from "../infra/qveris-client.mjs";

function toNumber(value) {
  if (value == null || value === "") return null;
  const n = Number(value);
  return Number.isFinite(n) ? n : null;
}

function pickFirst(...values) {
  for (const v of values) {
    if (v == null) continue;
    if (typeof v === "string" && v.trim() === "") continue;
    return v;
  }
  return null;
}

export function pickQuoteData(raw) {
  const data = raw?.result?.data || raw?.data || raw || {};
  if (Array.isArray(data) && Array.isArray(data[0]) && data[0][0]) {
    const row = data[0][0];
    return {
      symbol: row.thscode ?? null,
      price: toNumber(row.latest),
      open: toNumber(row.open),
      high: toNumber(row.high),
      low: toNumber(row.low),
      volume: toNumber(row.volume),
      percentChange: toNumber(row.changeRatio),
      marketCap: toNumber(row.mv ?? row.totalCapital),
      pe: toNumber(row.pe_ttm ?? row.pe),
      pb: toNumber(row.pb ?? row.pbr_lf),
      timestamp: row.time ?? row.tradeDate ?? null,
      raw: row,
    };
  }
  return {
    symbol: data.symbol ?? data.Symbol ?? null,
    price: toNumber(data.close ?? data.price ?? data.latestPrice),
    open: toNumber(data.open),
    high: toNumber(data.high),
    low: toNumber(data.low),
    volume: toNumber(data.volume),
    percentChange: toNumber(data.percent_change ?? data.changePercent),
    marketCap: toNumber(data.marketCap ?? data.mv ?? data.totalCapital),
    pe: toNumber(data.pe_ttm ?? data.pe ?? data.TrailingPE),
    pb: toNumber(data.pb ?? data.pbr_lf ?? data.PriceToBookRatio),
    timestamp: data.datetime ?? data.timestamp ?? data["07. latest trading day"] ?? null,
    raw: data,
  };
}

export function pickFundamentalData(raw) {
  const data = raw?.result?.data || raw?.data || raw || {};
  if (Array.isArray(data) && Array.isArray(data[0]) && data[0][0]) {
    const row = data[0][0];
    const statementType = row.statement_type || row.statementType || null;
    const period = row.time || row.end_date || row.report_date || null;
    const tagsRaw = pickFirst(
      row.ths_the_ths_concept_index_stock,
      row.ths_concept_stock,
      row.ths_concept_list_stock,
      row.concept,
      row.tags,
      row.tag_list,
      row.ths_tags_stock,
    );
    return {
      symbol: row.ths_thscode_stock ?? row.thscode ?? null,
      name: row.ths_corp_cn_name_stock ?? row.ths_short_name_stock ?? row.short_name ?? null,
      industry: pickFirst(
        row.ths_the_ths_industry_stock,
        row.ths_industry_stock,
        row.ths_industry_name_stock,
        row.ths_sw_industry_stock,
        row.industry,
        row.industry_name,
      ),
      mainBusiness: pickFirst(
        row.ths_main_businuess_stock,
        row.ths_main_business_stock,
        row.ths_business_scope_stock,
        row.ths_mo_product_name_stock,
        row.main_business,
        row.business_scope,
      ),
      tags: Array.isArray(tagsRaw)
        ? tagsRaw.filter(Boolean)
        : typeof tagsRaw === "string"
          ? tagsRaw
              .split(/[，,;；|]/)
              .map((x) => x.trim())
              .filter(Boolean)
          : null,
      marketCap: null,
      pe: null,
      forwardPe: null,
      pb: null,
      ps: null,
      profitMargin: null,
      revenueGrowthYoy: null,
      earningsGrowthYoy: null,
      week52High: null,
      week52Low: null,
      latestQuarter: period,
      statementType,
      reportPeriod: period,
      revenue: toNumber(row.ths_revenue_stock ?? row.ths_operating_total_revenue_stock),
      netProfit: toNumber(row.ths_np_atoopc_stock ?? row.ths_np_stock),
      totalAssets: toNumber(row.ths_total_assets_stock),
      totalLiabilities: toNumber(row.ths_total_liab_stock),
      operatingCashflow: toNumber(row.ths_ncf_from_oa_stock),
      raw: row,
    };
  }
  return {
    symbol: data.Symbol ?? data.symbol ?? null,
    name: data.Name ?? data.name ?? null,
    industry: pickFirst(data.Industry, data.industry, data.industry_name),
    mainBusiness: pickFirst(data.BusinessDescription, data.business_description, data.main_business),
    tags: Array.isArray(data.tags)
      ? data.tags
      : typeof (data.concept ?? data.ths_the_ths_concept_index_stock) === "string"
        ? String(data.concept ?? data.ths_the_ths_concept_index_stock)
            .split(/[，,;；|]/)
            .map((x) => x.trim())
            .filter(Boolean)
        : null,
    marketCap: toNumber(data.MarketCapitalization ?? data.marketCap),
    pe: toNumber(data.PERatio ?? data.pe ?? data.TrailingPE),
    forwardPe: toNumber(data.ForwardPE),
    pb: toNumber(data.PriceToBookRatio),
    ps: toNumber(data.PriceToSalesRatioTTM),
    profitMargin: toNumber(data.ProfitMargin),
    revenueGrowthYoy: toNumber(data.QuarterlyRevenueGrowthYOY),
    earningsGrowthYoy: toNumber(data.QuarterlyEarningsGrowthYOY),
    week52High: toNumber(data["52WeekHigh"] ?? data.fifty_two_week?.high),
    week52Low: toNumber(data["52WeekLow"] ?? data.fifty_two_week?.low),
    latestQuarter: data.LatestQuarter ?? null,
    raw: data,
  };
}

export function pickTechnicalData(raw) {
  const result = raw?.result || raw || {};
  const data = result?.data || result;
  if (Array.isArray(data) && Array.isArray(data[0]) && data[0][0]) {
    const rows = data[0];
    const row = rows[rows.length - 1];
    const prev5Index = Math.max(0, rows.length - 6);
    const prev5 = rows[prev5Index] || null;
    const closeNow = toNumber(row.close);
    const closePrev5 = toNumber(prev5?.close);
    const change5d =
      closeNow != null && closePrev5 != null && closePrev5 !== 0 ? ((closeNow - closePrev5) / closePrev5) * 100 : null;
    const currentVolume = toNumber(row.volume);
    const volumes = rows
      .slice(Math.max(0, rows.length - 6), rows.length - 1)
      .map((x) => toNumber(x.volume))
      .filter((x) => x != null);
    const avgPrev5Volume = volumes.length > 0 ? volumes.reduce((sum, x) => sum + x, 0) / volumes.length : null;
    const volumeRatio =
      currentVolume != null && avgPrev5Volume != null && avgPrev5Volume > 0 ? currentVolume / avgPrev5Volume : null;
    return {
      latestDate: row.time ?? null,
      rsi: null,
      close: closeNow,
      changeRatio: toNumber(row.changeRatio),
      change5d,
      volumeRatio,
      avgPrev5Volume,
      raw: row,
    };
  }
  if (data?.["Technical Analysis: RSI"]) {
    const entries = Object.entries(data["Technical Analysis: RSI"]);
    if (entries.length > 0) {
      const [date, value] = entries[0];
      return {
        latestDate: date,
        rsi: toNumber(value?.RSI),
        raw: data,
      };
    }
  }
  return {
    latestDate: null,
    rsi: null,
    raw: data,
  };
}

export function pickSentimentData(raw) {
  const payload = raw?.result || raw || {};
  const data = payload?.data || payload;
  const resolved = resolveToolPayloadSync(raw);
  const content = resolved.content ?? data;
  const hits = content?.data?.hits || content?.hits || [];
  if (Array.isArray(hits)) {
    const latest = hits[0]?.source || hits[0] || null;
    const sentimentScore = latest?.newsSentiment?.[0]?.sentimentScore ?? latest?.weChatSentiment?.[0]?.sentimentScore ?? null;
    const sourceLabel = latest?.type || latest?.sourceName || latest?.siteName || null;
    return {
      itemCount: hits.length,
      latestHeadline: latest?.title ?? null,
      latestTime: latest?.publishTime ?? latest?.effectiveTime ?? null,
      latestTickerSentiment: sentimentScore,
      sourceLabel,
      fullContentFileUrl: data?.full_content_file_url ?? null,
      raw: content,
    };
  }
  if (Array.isArray(content)) {
    const latest = content[0] || null;
    return {
      itemCount: content.length,
      latestHeadline: latest?.headline ?? null,
      latestTime: latest?.datetime ?? null,
      latestTickerSentiment: null,
      fullContentFileUrl: data?.full_content_file_url ?? null,
      raw: content,
    };
  }
  const feed = content?.feed || [];
  const latest = feed[0] || null;
  return {
    itemCount: toNumber(content?.items) ?? feed.length,
    latestHeadline: latest?.title ?? null,
    latestTime: latest?.time_published ?? null,
    latestTickerSentiment: latest?.ticker_sentiment?.[0]?.ticker_sentiment_label ?? null,
    fullContentFileUrl: data?.full_content_file_url ?? null,
    raw: content,
  };
}

export function pickXSentimentData(raw) {
  const payload = raw?.result || raw || {};
  const data = payload?.data ?? payload;
  const resolved = resolveToolPayloadSync(raw);
  const content = resolved.content ?? data;

  let posts = [];
  if (Array.isArray(content)) posts = content;
  else if (Array.isArray(content?.data)) posts = content.data;
  else if (Array.isArray(content?.posts)) posts = content.posts;
  else if (Array.isArray(content?.results)) posts = content.results;

  const first = posts[0] || null;
  return {
    itemCount: posts.length || toNumber(content?.total) || 0,
    topPostText: first?.text ?? first?.full_text ?? first?.title ?? first?.headline ?? null,
    topPostTime: first?.created_at ?? first?.time ?? first?.datetime ?? null,
    sourceMode: Array.isArray(content) ? "fallback" : "direct",
    fullContentFileUrl: data?.full_content_file_url ?? null,
    raw: content,
  };
}

export function parseCapability(capability, raw) {
  if (capability === "quote") return pickQuoteData(raw);
  if (capability === "fundamentals") return pickFundamentalData(raw);
  if (capability === "technicals") return pickTechnicalData(raw);
  if (capability === "sentiment") return pickSentimentData(raw);
  if (capability === "x_sentiment") return pickXSentimentData(raw);
  return raw;
}


path: scripts/lib/data/providers/base.mjs
content: export class DataProvider {
  constructor(name, markets = []) {
    this.name = name;
    this.markets = markets;
  }

  supportsMarket(market) {
    return this.markets.includes(market);
  }

  get capabilities() {
    throw new Error("Not implemented");
  }

  async fetch(_capability, _symbol, _context = {}) {
    throw new Error("Not implemented");
  }

  parse(_capability, _rawResponse) {
    throw new Error("Not implemented");
  }
}


path: scripts/lib/data/providers/ths-ifind.mjs
content: import { DataProvider } from "./base.mjs";
import { executeTool } from "../../infra/qveris-client.mjs";
import { parseCapability } from "../parser.mjs";
import { toThsCode } from "../../market/resolver.mjs";

function latestCompletedReportPeriod() {
  const now = new Date();
  const month = now.getMonth() + 1;
  let year = now.getFullYear();
  let period = "0930";
  if (month <= 4) {
    year -= 1;
    period = "0930";
  } else if (month <= 8) {
    period = "0331";
  } else if (month <= 10) {
    period = "0630";
  } else {
    period = "0930";
  }
  return { year: String(year), period };
}

function historyDateRange(days = 45) {
  const end = new Date();
  const start = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
  const fmt = (d) => d.toISOString().slice(0, 10);
  return { start: fmt(start), end: fmt(end) };
}

export class ThsIfindProvider extends DataProvider {
  constructor() {
    super("ths-ifind", ["CN", "HK"]);
  }

  get capabilities() {
    return ["quote", "fundamentals", "technicals"];
  }

  buildRequest(capability, symbol, market) {
    const code = toThsCode(symbol, market);
    if (capability === "quote") {
      return { toolId: "ths_ifind.real_time_quotation.v1", params: { codes: code } };
    }
    if (capability === "fundamentals") {
      const report = latestCompletedReportPeriod();
      return {
        toolId: "ths_ifind.financial_statements.v1",
        params: {
          statement_type: "income",
          codes: code,
          year: report.year,
          period: report.period,
          type: "1",
        },
      };
    }
    if (capability === "technicals") {
      const range = historyDateRange(45);
      return {
        toolId: "ths_ifind.history_quotation.v1",
        params: {
          codes: code,
          startdate: range.start,
          enddate: range.end,
          interval: "D",
        },
      };
    }
    return null;
  }

  async fetch(capability, symbol, context = {}) {
    const market = context.market || "CN";
    const request = this.buildRequest(capability, symbol, market);
    if (!request) return { success: false, reason: "unsupported capability" };
    const raw = await executeTool(
      request.toolId,
      context.searchId || null,
      request.params,
      context.maxSize || 30_000,
      context.timeoutMs || 25_000,
    );
    return {
      success: Boolean(raw?.success !== false),
      provider: this.name,
      toolId: request.toolId,
      params: request.params,
      raw,
      parsed: this.parse(capability, raw),
    };
  }

  parse(capability, rawResponse) {
    return parseCapability(capability, rawResponse);
  }
}


path: scripts/lib/data.mjs
content: import { isStale } from "./utils.mjs";

export function pickSectorBenchmarks(benchmarks, market) {
  if (market === "CN" || market === "HK") return benchmarks.electrical_equipment || benchmarks.default;
  if (market === "US") return benchmarks.semiconductor || benchmarks.default;
  return benchmarks.default;
}

export function summarizeQuality(payload) {
  const warnings = [];
  const quote = payload.quote?.parsed;
  const fundamentals = payload.fundamentals?.parsed;
  const technicals = payload.technicals?.parsed;
  const sentiment = payload.sentiment?.parsed;

  if (!quote?.price) warnings.push("行情缺少最新价格字段");
  if (!fundamentals?.pe && !fundamentals?.marketCap) warnings.push("基本面关键估值字段缺失");
  if (technicals && technicals.rsi == null && technicals.changeRatio == null) warnings.push("技术指标缺少 RSI/趋势字段");
  if (sentiment && !sentiment.itemCount) warnings.push("情绪数据缺少新闻条目");

  if (isStale(quote?.timestamp, 5)) warnings.push("行情数据可能已过期（超过 5 天）");
  if (isStale(fundamentals?.latestQuarter, 220)) warnings.push("基本面季度数据较旧");

  let confidence = "high";
  if (warnings.length >= 2) confidence = "medium";
  if (warnings.length >= 4) confidence = "low";
  return { confidence, warnings };
}



path: scripts/lib/events.mjs
content: function inferThemeTags(text) {
  const raw = String(text || "").toLowerCase();
  const themes = [];
  if (/ai|算力|gpu|大模型|芯片|semiconductor/.test(raw)) themes.push("AI算力");
  if (/新能源|光伏|储能|电池|ev|wind|solar/.test(raw)) themes.push("新能源");
  if (/消费|零售|餐饮|旅游|消费复苏/.test(raw)) themes.push("消费复苏");
  if (/监管|政策|罚款|审批|牌照/.test(raw)) themes.push("监管政策");
  if (/并购|收购|重组|merger|acquisition/.test(raw)) themes.push("并购重组");
  if (/业绩|财报|盈利预警|guidance|earnings/.test(raw)) themes.push("业绩指引");
  return themes.length ? themes : ["市场情绪"];
}

function inferSentiment(text) {
  const raw = String(text || "").toLowerCase();
  if (/增长|超预期|创新高|利好|beat|strong|surge|record/.test(raw)) return "positive";
  if (/下滑|不及预期|风险|利空|warn|drop|decline|lawsuit/.test(raw)) return "negative";
  return "neutral";
}

export function extractEvents(sentiment, xSentiment, options = {}) {
  const events = [];
  const maxEvents = Number(options.maxEvents || 8);
  const sentItems = sentiment?.raw?.data?.hits || sentiment?.raw?.hits || sentiment?.raw?.feed || [];
  const xItems = Array.isArray(xSentiment?.raw)
    ? xSentiment.raw
    : Array.isArray(xSentiment?.raw?.data)
      ? xSentiment.raw.data
      : Array.isArray(xSentiment?.raw?.posts)
        ? xSentiment.raw.posts
        : [];

  for (const item of sentItems.slice(0, maxEvents)) {
    const src = item?.source || item || {};
    const title = src.title || src.headline || src.summary || null;
    if (!title) continue;
    const text = `${title} ${src.content || ""}`;
    events.push({
      title,
      time: src.publishTime || src.effectiveTime || src.datetime || null,
      source: src.type || src.sourceName || "news",
      themeTags: inferThemeTags(text),
      sentiment: inferSentiment(text),
      impactPath: "事件可能通过预期变化影响估值与交易情绪",
      confidence: "medium",
      counterpoints: ["需等待后续数据验证事件持续性"],
    });
  }

  for (const post of xItems.slice(0, maxEvents)) {
    const text = post?.text || post?.full_text || post?.title || null;
    if (!text) continue;
    events.push({
      title: text.slice(0, 140),
      time: post?.created_at || post?.datetime || null,
      source: "x",
      themeTags: inferThemeTags(text),
      sentiment: inferSentiment(text),
      impactPath: "社交热度变化可能先于价格波动，但噪音较大",
      confidence: "low",
      counterpoints: ["社交媒体观点存在样本偏差与情绪噪音"],
    });
  }

  return events.slice(0, maxEvents);
}

export function clusterEventsByTheme(events = []) {
  const grouped = new Map();
  for (const evt of events) {
    const tags = evt.themeTags?.length ? evt.themeTags : ["市场情绪"];
    for (const tag of tags) {
      if (!grouped.has(tag)) grouped.set(tag, []);
      grouped.get(tag).push(evt);
    }
  }
  return [...grouped.entries()].map(([name, items]) => ({
    name,
    eventCount: items.length,
    keyEvents: items.slice(0, 3).map((x) => x.title),
    trackingIndicator: "事件后3-10个交易日的量价与业绩预期变化",
  }));
}

export function buildEventRadarIdeas(events = [], inputSymbol, market = "GLOBAL") {
  const ideas = [];
  if (inputSymbol) {
    ideas.push({
      ticker: inputSymbol,
      market,
      direction: "long",
      rationale: "主标的事件关注度上升，可跟踪催化兑现情况",
      entryTrigger: "回踩关键均线后放量转强",
      positionSize: "首仓 20%-30%，分批加仓",
      stopLoss: "-8% 或趋势破位",
      invalidation: "核心事件被证伪或业绩不及预期",
    });
  }
  const themeCounter = new Map();
  for (const evt of events) {
    for (const tag of evt.themeTags || []) themeCounter.set(tag, (themeCounter.get(tag) || 0) + 1);
  }
  const topThemes = [...themeCounter.entries()]
    .sort((a, b) => b[1] - a[1])
    .slice(0, 3)
    .map(([x]) => x);
  for (const theme of topThemes) {
    ideas.push({
      ticker: `${theme}-basket`,
      market: "GLOBAL",
      direction: "long",
      rationale: `主题“${theme}”事件密度较高，具备阶段性交易机会`,
      entryTrigger: "主题相关标的出现趋势共振与成交放大",
      positionSize: "主题仓位不超过总仓位 30%",
      stopLoss: "主题热度快速衰减且领涨股破位",
      invalidation: "政策/基本面数据与主题叙事背离",
    });
  }
  return ideas.slice(0, 5);
}



path: scripts/lib/infra/qveris-client.mjs
content: const BASE_URL = "https://qveris.ai/api/v1";
const DEFAULT_FULL_CONTENT_ALLOWED_HOSTS = ["qveris.ai"];

function timeoutSignal(timeoutMs) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  return { signal: controller.signal, cleanup: () => clearTimeout(timer) };
}

export function safeJsonParse(value) {
  if (!value || typeof value !== "string") return null;
  try {
    return JSON.parse(value);
  } catch {
    return null;
  }
}

function pickPayload(rawResult) {
  return rawResult?.result?.data ?? rawResult?.data ?? rawResult?.result ?? rawResult ?? {};
}

function pickFullContentFileUrl(rawResult, payload) {
  return (
    payload?.full_content_file_url ??
    rawResult?.result?.full_content_file_url ??
    rawResult?.data?.full_content_file_url ??
    null
  );
}

function isAllowedHost(hostname, allowedHosts = []) {
  const host = String(hostname || "").toLowerCase();
  if (!host) return false;
  for (const allowed of allowedHosts) {
    const base = String(allowed || "").toLowerCase().trim();
    if (!base) continue;
    if (host === base || host.endsWith(`.${base}`)) return true;
  }
  return false;
}

function validateFullContentUrl(rawUrl, allowedHosts = DEFAULT_FULL_CONTENT_ALLOWED_HOSTS) {
  if (!rawUrl || typeof rawUrl !== "string") {
    return { ok: false, reason: "missing full content url", url: null };
  }
  let parsed;
  try {
    parsed = new URL(rawUrl);
  } catch {
    return { ok: false, reason: "invalid full content url", url: null };
  }
  const protocol = String(parsed.protocol || "").toLowerCase();
  if (protocol !== "https:") {
    return { ok: false, reason: `unsupported protocol: ${protocol || "unknown"}`, url: parsed.toString() };
  }
  if (!isAllowedHost(parsed.hostname, allowedHosts)) {
    return { ok: false, reason: `blocked host: ${parsed.hostname}`, url: parsed.toString() };
  }
  return { ok: true, reason: null, url: parsed.toString() };
}

export function resolveToolPayloadSync(rawResult) {
  const payload = pickPayload(rawResult);
  const truncated = payload?.truncated_content;
  const parsed = safeJsonParse(truncated);
  const fullContentFileUrl = pickFullContentFileUrl(rawResult, payload);
  const hasTruncatedContent = typeof truncated === "string";
  if (parsed != null) {
    return {
      content: parsed,
      meta: {
        contentMode: "truncated_content",
        hasTruncatedContent,
        fullContentFileUrl,
      },
    };
  }
  return {
    content: payload,
    meta: {
      contentMode: "payload",
      hasTruncatedContent,
      fullContentFileUrl,
    },
  };
}

export async function resolveToolPayload(rawResult, options = {}) {
  const initial = resolveToolPayloadSync(rawResult);
  if (initial.meta.contentMode !== "payload") return initial;
  if (!initial.meta.hasTruncatedContent) return initial;
  if (options.fetchFullContent === false) return initial;
  if (!initial.meta.fullContentFileUrl) return initial;

  const allowedHosts = Array.isArray(options.fullContentAllowedHosts) && options.fullContentAllowedHosts.length > 0
    ? options.fullContentAllowedHosts
    : DEFAULT_FULL_CONTENT_ALLOWED_HOSTS;
  const checked = validateFullContentUrl(initial.meta.fullContentFileUrl, allowedHosts);
  if (!checked.ok) {
    return {
      ...initial,
      meta: {
        ...initial.meta,
        fetchError: checked.reason,
      },
    };
  }

  const fetchImpl = options.fetchImpl || fetch;
  const timeoutMs = Number(options.timeoutMs || 10_000);
  const { signal, cleanup } = timeoutSignal(timeoutMs);
  try {
    const res = await fetchImpl(checked.url, { signal });
    if (!res.ok) {
      return {
        ...initial,
        meta: {
          ...initial.meta,
          fetchError: `full content fetch failed: ${res.status}`,
        },
      };
    }
    const fullContent = await res.json();
    return {
      content: fullContent,
      meta: {
        contentMode: "full_content_file_url",
        hasTruncatedContent: true,
        fullContentFileUrl: checked.url,
      },
    };
  } catch (error) {
    return {
      ...initial,
      meta: {
        ...initial.meta,
        fetchError: error?.message || String(error),
      },
    };
  } finally {
    cleanup();
  }
}

export function getApiKey() {
  const key = process.env.QVERIS_API_KEY;
  if (!key) {
    console.error("Error: QVERIS_API_KEY environment variable is required.");
    process.exit(1);
  }
  return key;
}

export async function searchTools(query, limit = 10, timeoutMs = 25_000) {
  const [REDACTED_SECRET]);
  const { signal, cleanup } = timeoutSignal(timeoutMs);
  try {
    const res = await fetch(`${BASE_URL}/search`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ query, limit }),
      signal,
    });
    if (!res.ok) {
      throw new Error(`Search failed (${res.status}): ${await res.text()}`);
    }
    return await res.json();
  } finally {
    cleanup();
  }
}

export async function executeTool(toolId, searchId, parameters, maxResponseSize = 30_000, timeoutMs = 25_000) {
  const [REDACTED_SECRET]);
  const { signal, cleanup } = timeoutSignal(timeoutMs);
  try {
    const url = new URL(`${BASE_URL}/tools/execute`);
    url.searchParams.set("tool_id", toolId);
    const res = await fetch(url.toString(), {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        search_id: searchId,
        parameters,
        max_response_size: maxResponseSize,
      }),
      signal,
    });
    if (!res.ok) {
      throw new Error(`Execute failed (${res.status}): ${await res.text()}`);
    }
    return await res.json();
  } finally {
    cleanup();
  }
}


path: scripts/lib/market/resolver.mjs
content: import { executeTool, searchTools } from "../infra/qveris-client.mjs";

const THS_CODE_CONVERTER_TOOL_ID = "ths_ifind.code_converter.v1";
const CODE_CONVERTER_TIMEOUT_MS = 5000;

const DEFAULT_COMPANY_SYMBOL_ALIASES = {
  特变电工: { symbol: "600089.SH", market: "CN" },
  英伟达: { symbol: "NVDA", market: "US" },
  腾讯: { symbol: "0700.HK", market: "HK" },
  腾讯控股: { symbol: "0700.HK", market: "HK" },
  贵州茅台: { symbol: "600519.SH", market: "CN" },
};

export function hasCjk(text) {
  return /[\u3400-\u9FFF]/.test(String(text || ""));
}

export function normalizeAliasKey(value) {
  return String(value || "")
    .trim()
    .replace(/[（(]\s*[0-9A-Za-z._-]+\s*[)）]/g, "")
    .replace(/\s+/g, "");
}

export function extractTickerFromText(value) {
  const raw = String(value || "").trim();
  const withSuffix = raw.match(/([0-9]{4,6}\.(?:HK|SH|SZ|SS))/i);
  if (withSuffix?.[1]) return withSuffix[1].toUpperCase();
  const sixDigits = raw.match(/\b([0-9]{6})\b/);
  if (sixDigits?.[1]) return sixDigits[1];
  const fourDigits = raw.match(/\b([0-9]{4})\b/);
  if (fourDigits?.[1]) return fourDigits[1];
  return null;
}

export function inferMarketFromSymbol(value) {
  const raw = String(value || "").trim().toUpperCase();
  if (!raw) return "GLOBAL";
  if (raw.endsWith(".HK") || /^[0-9]{4,5}$/.test(raw)) return "HK";
  if (/\.(SH|SZ|SS)$/.test(raw) || /^[0-9]{6}$/.test(raw)) return "CN";
  if (raw.endsWith(".US") || /^[A-Z]{1,6}$/.test(raw)) return "US";
  if (hasCjk(raw)) return "CN";
  return "GLOBAL";
}

function splitCandidateCodes(rawValue) {
  const raw = String(rawValue || "").trim();
  if (!raw) return [];
  return raw
    .split(/[,\s;；|]+/)
    .map((x) => x.trim().toUpperCase())
    .filter(Boolean);
}

function isCodeMatchingMarket(code, market) {
  const c = String(code || "").toUpperCase();
  if (!c) return false;
  if (market === "HK") return /\.HK$/.test(c) || /^\d{4,5}$/.test(c);
  if (market === "CN") return /\.(SH|SZ|SS)$/.test(c) || /^\d{6}$/.test(c);
  if (market === "US") return /^[A-Z]{1,6}(\.US)?$/.test(c);
  return true;
}

function scoreCandidateCode(code, preferredMarket) {
  const c = String(code || "").toUpperCase();
  if (!c) return -999;
  let score = 0;
  if (isCodeMatchingMarket(c, preferredMarket)) score += 100;
  if (preferredMarket === "HK") {
    if (/^\d{4,5}\.HK$/.test(c)) score += 40;
    if (/^\d{4,5}$/.test(c)) score += 20;
  } else if (preferredMarket === "CN") {
    if (/^\d{6}\.(SH|SZ|SS)$/.test(c)) score += 40;
    if (/^\d{6}$/.test(c)) score += 20;
  } else if (preferredMarket === "US") {
    if (/^[A-Z]{1,6}$/.test(c)) score += 35;
    if (/^[A-Z]{1,6}\.US$/.test(c)) score += 25;
  } else if (/\.(HK|SH|SZ|SS|US)$/.test(c) || /^[A-Z]{1,6}$/.test(c)) {
    score += 10;
  }
  if (/\.PQ$/.test(c)) score -= 30;
  return score;
}

function normalizeResolvedCode(code, preferredMarket) {
  const c = String(code || "").toUpperCase();
  if (!c) return null;
  if (preferredMarket === "HK" && /^\d{4,5}$/.test(c)) return `${c.padStart(4, "0")}.HK`;
  if (preferredMarket === "CN" && /^\d{6}$/.test(c)) return c.startsWith("6") ? `${c}.SH` : `${c}.SZ`;
  if (preferredMarket === "US" && /^[A-Z]{1,6}\.US$/.test(c)) return c.replace(/\.US$/, "");
  return c;
}

function generateResolverNameVariants(companyName, preferredMarket = "GLOBAL") {
  const base = String(companyName || "").trim();
  if (!base) return [];
  const variants = new Set([base]);
  const normalized = base
    .replace(/(集团|控股|股份|有限责任公司|有限公司)$/g, "")
    .trim();
  if (normalized && normalized !== base) variants.add(normalized);
  if (preferredMarket === "HK") {
    const seed = [...variants];
    for (const v of seed) variants.add(`${v}-W`);
  }
  return [...variants];
}

function extractBestCodeFromConverterRows(rows, preferredMarket) {
  const candidates = [];
  for (const row of rows || []) {
    const thscodeValues = row?.table?.thscode;
    if (!Array.isArray(thscodeValues)) continue;
    for (const item of thscodeValues) {
      for (const code of splitCandidateCodes(item)) candidates.push(code);
    }
  }
  if (candidates.length === 0) return null;
  const unique = [...new Set(candidates)];
  unique.sort((a, b) => scoreCandidateCode(b, preferredMarket) - scoreCandidateCode(a, preferredMarket));
  return normalizeResolvedCode(unique[0], preferredMarket);
}

export async function resolveCompanyNameViaQveris(companyName, preferredMarket = "GLOBAL") {
  try {
    const nameVariants = generateResolverNameVariants(companyName, preferredMarket);
    if (nameVariants.length === 0) return null;
    const searchRes = await searchTools("ths_ifind 证券代码转换 code converter", 5, CODE_CONVERTER_TIMEOUT_MS);
    const searchId = searchRes?.search_id;
    if (!searchId) return null;
    for (const name of nameVariants) {
      const params = { mode: "secname", secname: name, isexact: "0" };
      const result = await executeTool(THS_CODE_CONVERTER_TOOL_ID, searchId, params, 20480, CODE_CONVERTER_TIMEOUT_MS);
      if (!result?.success) continue;
      const data = result?.result?.data;
      if (!Array.isArray(data) || data.length === 0) continue;
      const resolvedSymbol = extractBestCodeFromConverterRows(data, preferredMarket);
      if (resolvedSymbol) {
        if (preferredMarket !== "GLOBAL" && !isCodeMatchingMarket(resolvedSymbol, preferredMarket)) continue;
        return resolvedSymbol;
      }
    }
    return null;
  } catch {
    return null;
  }
}

export async function resolveRequestedSymbol(input, preferredMarket = "GLOBAL", aliasMap = DEFAULT_COMPANY_SYMBOL_ALIASES) {
  const original = String(input || "").trim();
  const aliasKey = normalizeAliasKey(original);
  const alias = aliasMap[aliasKey];
  if (alias) {
    return {
      original,
      symbol: alias.symbol,
      market: preferredMarket !== "GLOBAL" ? preferredMarket : alias.market,
      resolvedBy: "alias",
    };
  }

  const extractedTicker = extractTickerFromText(original);
  if (extractedTicker) {
    const market = preferredMarket !== "GLOBAL" ? preferredMarket : inferMarketFromSymbol(extractedTicker);
    return { original, symbol: extractedTicker, market, resolvedBy: "ticker-extract" };
  }

  if (hasCjk(original) && !/[0-9]/.test(original)) {
    const dynamicSymbol = await resolveCompanyNameViaQveris(original, preferredMarket);
    if (dynamicSymbol) {
      const market = preferredMarket !== "GLOBAL" ? preferredMarket : inferMarketFromSymbol(dynamicSymbol);
      return { original, symbol: dynamicSymbol, market, resolvedBy: "qveris-code-converter" };
    }
  }

  const inferred = preferredMarket !== "GLOBAL" ? preferredMarket : inferMarketFromSymbol(original);
  return { original, symbol: original, market: inferred, resolvedBy: hasCjk(original) ? "cjk-default" : "input" };
}

export function normalizeSymbols(input, market) {
  const raw = (input || "").trim();
  if (!raw) return [];
  const candidates = new Set([raw.toUpperCase()]);
  const upper = raw.toUpperCase();

  if (market === "US") {
    candidates.add(upper.replace(/\.US$/, ""));
    candidates.add(`${upper.replace(/\.US$/, "")}.US`);
  } else if (market === "HK") {
    const core = upper.replace(/\.HK$/, "").replace(/^0+/, "") || "0";
    candidates.add(`${core}.HK`);
    candidates.add(`${core.padStart(4, "0")}.HK`);
    candidates.add(core.padStart(4, "0"));
  } else if (market === "CN") {
    const core = upper.replace(/\.(SS|SH|SZ)$/, "");
    candidates.add(`${core}.SS`);
    candidates.add(`${core}.SH`);
    candidates.add(`${core}.SZ`);
  } else {
    candidates.add(`${upper}.US`);
    if (/^\d+$/.test(upper)) {
      candidates.add(`${upper}.HK`);
      candidates.add(`${upper}.SS`);
      candidates.add(`${upper}.SZ`);
    }
  }

  return [...candidates];
}

export function toThsCode(symbol, market) {
  const upper = (symbol || "").toUpperCase();
  if (upper.includes(".")) return upper;
  if (market === "CN") {
    if (upper.startsWith("6")) return `${upper}.SH`;
    return `${upper}.SZ`;
  }
  if (market === "HK") {
    return `${upper.replace(/^0+/, "").padStart(4, "0")}.HK`;
  }
  return upper;
}

export { DEFAULT_COMPANY_SYMBOL_ALIASES };


path: scripts/lib/output/chat/emoji.mjs
content: export function gradeEmoji(grade) {
  const g = String(grade || "").toUpperCase();
  if (g.startsWith("A") || g.startsWith("B+")) return "🟢";
  if (g.startsWith("B") || g.startsWith("C+")) return "🟡";
  if (g.startsWith("C") || g.startsWith("D")) return "🔴";
  return "⚪";
}

export function riskEmoji(risk) {
  const r = String(risk || "").toLowerCase();
  if (r === "low") return "🟢";
  if (r === "medium") return "🟡";
  if (r === "high") return "🔴";
  return "⚪";
}


path: scripts/lib/output/chat/formatter.mjs
content: import {
  buildFundamentalsSegment,
  buildQuoteSegment,
  buildRecommendationSegment,
  buildSentimentSegment,
  buildSignalSummary,
} from "./segments.mjs";

export function formatForChat(result, options = {}) {
  const segments = [];
  segments.push({ type: "summary", content: buildSignalSummary(result), canExpand: true });
  if (options.includeDetails !== false) {
    segments.push({ type: "quote", content: buildQuoteSegment(result) });
    segments.push({ type: "fundamentals", content: buildFundamentalsSegment(result) });
    segments.push({ type: "sentiment", content: buildSentimentSegment(result) });
    segments.push({ type: "recommendation", content: buildRecommendationSegment(result) });
  }
  return {
    symbol: result?.symbol || "N/A",
    market: result?.market || "GLOBAL",
    segments,
  };
}


path: scripts/lib/output/chat/segments.mjs
content: import { gradeEmoji, riskEmoji } from "./emoji.mjs";

export function buildSignalSummary(result) {
  const grade = result?.analysis?.scorecard?.grade || "N/A";
  const signal = result?.analysis?.recommendation?.signal || "N/A";
  const risk = result?.analysis?.chaseRisk?.risk || "unknown";
  return `${gradeEmoji(grade)} ${result.symbol} | 评级 ${grade} | 信号 ${signal} | 风险 ${riskEmoji(risk)} ${risk}`;
}

export function buildQuoteSegment(result) {
  const q = result?.data?.quote?.parsed || {};
  return `行情: 现价 ${q.price ?? "N/A"}，涨跌 ${q.percentChange ?? "N/A"}%，量 ${q.volume ?? "N/A"}`;
}

export function buildFundamentalsSegment(result) {
  const f = result?.data?.fundamentals?.parsed || {};
  return `基本面: PE ${f.pe ?? "N/A"}，PB ${f.pb ?? "N/A"}，营收 ${f.revenue ?? "N/A"}，净利 ${f.netProfit ?? "N/A"}`;
}

export function buildSentimentSegment(result) {
  const s = result?.data?.sentiment?.parsed || {};
  const x = result?.data?.x_sentiment?.parsed || {};
  return `情绪: 新闻 ${s.itemCount ?? 0} 条，X 热度 ${x.itemCount ?? 0}`;
}

export function buildRecommendationSegment(result) {
  const r = result?.analysis?.recommendation || {};
  return `策略: ${r.signal || "N/A"}，周期 ${r.horizon || "N/A"}`;
}


path: scripts/lib/output/decision-card.mjs
content: function scoreToLight(score) {
  if (!Number.isFinite(score)) return { emoji: "⚪", label: "未评级" };
  if (score >= 75) return { emoji: "🟢", label: "偏积极" };
  if (score >= 55) return { emoji: "🟡", label: "中性" };
  return { emoji: "🔴", label: "偏谨慎" };
}

export function buildDecisionCard(report) {
  const score = report?.analysis?.scorecard?.composite;
  const grade = report?.analysis?.scorecard?.grade || "N/A";
  const signal = report?.analysis?.recommendation?.signal || "N/A";
  const risk = report?.analysis?.chaseRisk?.risk || "unknown";
  const light = scoreToLight(score);

  return {
    symbol: report?.symbol || "N/A",
    market: report?.market || "GLOBAL",
    light: light.label,
    emoji: light.emoji,
    score: Number.isFinite(score) ? score : null,
    grade,
    signal,
    risk,
    asText: `${light.emoji} ${report?.symbol || "N/A"} | ${grade} | ${signal} | 风险:${risk}`,
  };
}


path: scripts/lib/output.mjs
content: import { formatAmountByMarket, formatNum, formatPct, formatRatioPct, normalizeNewlines, maxConsecutiveCharRun, toFencedCodeBlock } from "./utils.mjs";

const REPORTIFY_CUSTOM_MAX_BLOCKS = 6;
const REPORTIFY_CUSTOM_MAX_CHARS_PER_BLOCK = 12000;

function redactSecrets(text) {
  if (!text) return text;
  return String(text)
    .replace(/Bearer\s+[A-Za-z0-9\-\._~\+\/]+=*/gi, "Bearer [REDACTED]")
    .replace(/QVERIS_API_KEY\s*[:=]\s*['"]?[^'"\s]+['"]?/gi, "QVERIS_API_KEY=[REDACTED]")
    .replace(/("?(?:qveris_api_key|authorization)"?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,}]+)/gi, "$1[REDACTED]");
}

export function extractReportifyCustomBlocks(inputText) {
  const text = String(inputText || "");
  const matches = text.match(/<reportify-custom[\s\S]*?<\/reportify-custom>/gi);
  if (!matches || matches.length === 0) return [];
  return matches.slice(0, REPORTIFY_CUSTOM_MAX_BLOCKS);
}

export { toFencedCodeBlock, normalizeNewlines, maxConsecutiveCharRun };

export function formatReportifyCustomBlocksSafe(inputText) {
  const blocks = extractReportifyCustomBlocks(inputText);
  if (blocks.length === 0) return [];
  const lines = [];
  lines.push("## reportify-custom（安全呈现）");
  lines.push("> 以下内容来自原始输入。为降低 XSS 风险，已按纯文本代码块输出（不会作为 Markdown/HTML 执行）。");
  for (const rawBlock of blocks) {
    const redacted = redactSecrets(rawBlock);
    const clipped =
      redacted.length > REPORTIFY_CUSTOM_MAX_CHARS_PER_BLOCK
        ? `${redacted.slice(0, REPORTIFY_CUSTOM_MAX_CHARS_PER_BLOCK)}\n...[truncated]...`
        : redacted;
    lines.push("");
    lines.push(toFencedCodeBlock(clipped, "reportify-custom"));
  }
  return lines;
}

export function formatQuestionnaireMarkdown(questionnaire) {
  const lines = [];
  lines.push("# 投资偏好问卷");
  lines.push("");
  for (const q of questionnaire.questions || []) {
    lines.push(`## ${q.prompt}`);
    for (const opt of q.options || []) lines.push(`- ${opt.id}: ${opt.label}`);
    lines.push("");
  }
  lines.push(`> ${questionnaire.instructions || ""}`);
  return lines.join("\n");
}

export function formatMarkdown(result) {
  if (result?.questionnaire && !result?.analysis) {
    return formatQuestionnaireMarkdown(result.questionnaire);
  }

  const { symbol, market, mode, data, quality, evolution, analysis } = result;
  const q = data.quote?.parsed || {};
  const f = data.fundamentals?.parsed || {};
  const t = data.technicals?.parsed || {};
  const s = data.sentiment?.parsed || {};
  const x = data.x_sentiment?.parsed || {};
  const scorecard = analysis?.scorecard || {};
  const safetyMargin = analysis?.safetyMargin || {};
  const chaseRisk = analysis?.chaseRisk || {};
  const recommendation = analysis?.recommendation || {};
  const thesis = analysis?.thesis || {};
  const valuationFrame = analysis?.valuationFrame || {};
  const eventRadar = analysis?.eventRadar || {};
  const now = new Date().toISOString();
  const summaryOnly = Boolean(result?.runtime?.summaryOnly);
  const lines = [];

  lines.push(`# ${symbol} 分析数据包`);
  lines.push(`> 数据截止时间: ${now}`);
  lines.push(`> 市场: ${market} | 模式: ${mode} | 质量置信度: ${quality.confidence}`);
  lines.push("");

  lines.push("## 核心快照");
  lines.push(`- 综合评级: ${scorecard.grade ?? "N/A"} (${scorecard.composite ?? "N/A"}/100)`);
  lines.push(`- 当前信号: ${recommendation.signal ?? "N/A"} | 时序风险: ${chaseRisk.risk ?? "unknown"} (${chaseRisk.classification ?? "N/A"})`);
  lines.push(`- 估值结论: ${valuationFrame.verdict || "N/A"} | 安全边际: ${formatPct(safetyMargin.margin)}`);
  lines.push(`- 最新价: ${formatNum(q.price)} | 当日涨跌: ${formatPct(q.percentChange)} | 近5日涨跌: ${formatPct(t.change5d)}`);
  lines.push("");

  lines.push("## 估值与财务");
  lines.push(`- 估值: PE ${formatNum(f.pe)} | PB ${formatNum(f.pb)} | 市值 ${formatAmountByMarket(f.marketCap, market)}`);
  lines.push(`- 财务: 营收 ${formatAmountByMarket(f.revenue, market)} | 净利润 ${formatAmountByMarket(f.netProfit, market)}`);
  lines.push(`- 现金与质量: 经营现金流 ${formatAmountByMarket(f.operatingCashflow, market)} | 利润率 ${formatRatioPct(f.profitMargin)}`);
  lines.push("");

  lines.push("## 事件与情绪");
  const events = eventRadar.events || [];
  if (events.length === 0) {
    lines.push("- 暂无高置信度事件");
  } else {
    for (const evt of events.slice(0, 3)) {
      lines.push(`- [${evt.source}] ${evt.title} (${evt.sentiment}, 主题: ${(evt.themeTags || []).join("/") || "N/A"})`);
    }
  }
  lines.push(`- 新闻热度: ${s.itemCount ?? "N/A"} | X热度: ${x.itemCount ?? "N/A"}`);
  lines.push("");

  lines.push("## 投资逻辑线索（供 OpenClaw 推理）");
  const bullishDrivers = Array.isArray(thesis?.drivers) ? thesis.drivers.slice(0, 2) : [];
  const bearishRisks = Array.isArray(thesis?.risks) ? thesis.risks.slice(0, 3) : [];
  lines.push(`- 看多驱动: ${bullishDrivers.length > 0 ? bullishDrivers.join("；") : "N/A"}`);
  lines.push(`- 看空风险: ${bearishRisks.length > 0 ? bearishRisks.join("；") : "N/A"}`);
  lines.push(`- 当前结论线索: 信号=${recommendation.signal ?? "N/A"}，估值=${valuationFrame.verdict || "N/A"}，追高风险=${chaseRisk.risk ?? "unknown"}`);
  lines.push("");

  lines.push("## 投资建议线索（供 OpenClaw 生成）");
  lines.push("- 空仓: 优先等待支撑确认后分批建仓，避免追高放量日。");
  lines.push("- 轻仓: 仅在事件与量价共振时加仓，否则保持观察仓位。");
  lines.push("- 重仓: 关注压力位与事件兑现节奏，必要时分批止盈/降风险。");
  lines.push("- 失效条件: 跌破关键支撑且放量，或核心基本面/政策逻辑被证伪。");
  lines.push("");

  if (!summaryOnly) {
    lines.push("## 补充证据");
    lines.push(`- 行业: ${thesis.businessSummary?.industry || "N/A"} | 主营: ${thesis.businessSummary?.mainBusiness || "N/A"}`);
    lines.push(`- 情景(基准): ${thesis.scenarios?.base?.condition || "N/A"}（概率 ${thesis.scenarios?.base?.probability || "N/A"}）`);
    lines.push(`- 透明度: route=${JSON.stringify(evolution?.route_source || {})} | template_hits=${evolution?.template_hits ?? 0}`);
    lines.push("");
  }

  lines.push("---");
  lines.push("*分析建议：请 OpenClaw 根据 SKILL.md 中的 Single Stock Analysis Guide 输出专业且不过长的报告（必须包含投资逻辑与投资建议）*");
  lines.push("");
  lines.push("## 风险提示与免责声明");
  for (const warning of quality.warnings || []) lines.push(`- ${warning}`);
  lines.push("- 本输出为研究参考，不构成投资建议。");
  lines.push("- 市场有风险，投资需谨慎。");

  const customSection = formatReportifyCustomBlocksSafe(result?.runtime?.resolvedInput?.original);
  if (customSection.length > 0) {
    lines.push("");
    lines.push(...customSection);
  }
  return lines.join("\n");
}



path: scripts/lib/playbooks.mjs
content: function withCommonFields(style, fitFor, actionable) {
  return {
    style,
    fitFor,
    actionable: Boolean(actionable),
    watchlist: ["营收同比", "净利润同比", "经营现金流", "估值分位", "量价趋势", "事件催化兑现度"],
  };
}

export function buildPlaybooks(context, preferences = {}) {
  const { valuationFrame, chaseRisk, scorecard } = context;
  const mos = valuationFrame?.intrinsic?.marginOfSafety;
  const riskLevel = preferences.risk || "mid";

  const valueDefensive = {
    ...withCommonFields("value_defensive", "回撤敏感、偏长期配置", preferences.actionable),
    entryTriggers: [
      `安全边际 ${mos == null ? "待确认" : `${mos.toFixed(1)}%`} 且估值不高于行业中位`,
      "基本面关键指标无明显恶化",
    ],
    positionSizing: riskLevel === "low" ? "首仓 15%，最多 40%" : "首仓 20%，最多 50%",
    exitPlan: {
      takeProfit: "估值显著高估或达到目标收益区间时分批止盈",
      stopLoss: "单笔 -10% 或基本面证伪",
      timeStop: "6-12个月逻辑未兑现则重新评估",
      fundamentalInvalidation: "营收/利润连续恶化且现金流转负",
    },
    riskControls: ["避免满仓单一标的", "财务杠杆高时降低仓位", "事件驱动高波动期谨慎加仓"],
  };

  const balanced = {
    ...withCommonFields("balanced", "中期持有、风险收益均衡", preferences.actionable),
    entryTriggers: ["估值处于合理区间", "趋势确认且非极端追高", `追高风险: ${chaseRisk?.risk || "unknown"}`],
    positionSizing: "首仓 25%-30%，最多 60%",
    exitPlan: {
      takeProfit: "达到目标收益后分批减仓",
      stopLoss: "单笔 -8% 至 -10%",
      timeStop: "3-6个月未达预期则调整",
      fundamentalInvalidation: "核心驱动因子失效",
    },
    riskControls: ["至少分两批建仓", "趋势转弱时先降杠杆", "消息面冲击日降低操作频率"],
  };

  const growthAggressive = {
    ...withCommonFields("growth_aggressive", "波动容忍高、追求超额收益", preferences.actionable),
    entryTriggers: ["增长指标改善", "主题催化明确", "趋势放量上行"],
    positionSizing: riskLevel === "high" ? "首仓 30%，最多 70%" : "首仓 20%，最多 60%",
    exitPlan: {
      takeProfit: "趋势转弱或催化兑现后减仓",
      stopLoss: "动态止损，通常 -7% 至 -9%",
      timeStop: "1-3个月验证增长持续性",
      fundamentalInvalidation: "增长预期明显下修",
    },
    riskControls: ["单一主题仓位上限 30%", "避免高位连续加仓", "高波动期增加止损纪律"],
  };

  const tradingSwing = {
    ...withCommonFields("trading_swing", "短线/事件交易", preferences.actionable),
    entryTriggers: ["事件催化触发", "量价配合", "交易回踩型中等待回踩确认"],
    positionSizing: "单笔 <= 10%，总交易仓位 <= 40%",
    exitPlan: {
      takeProfit: "快进快出，分批止盈",
      stopLoss: "单笔 -5% 至 -7% 严格执行",
      timeStop: "5-20个交易日",
      fundamentalInvalidation: "事件证伪或热度快速衰退",
    },
    riskControls: ["不隔夜重仓赌消息", "事件后首日避免情绪化追高", "连续亏损暂停交易复盘"],
  };

  const all = [valueDefensive, balanced, growthAggressive, tradingSwing];
  const styleMap = {
    value: "value_defensive",
    balanced: "balanced",
    growth: "growth_aggressive",
    trading: "trading_swing",
    value_defensive: "value_defensive",
    growth_aggressive: "growth_aggressive",
  };
  const preferred = styleMap[preferences.style] || "balanced";
  const recommended = all.find((x) => x.style === preferred) || balanced;
  return {
    recommendedStyle: recommended.style,
    recommendedReason: `当前综合评分 ${scorecard?.composite ?? "N/A"}，估值结论 ${valuationFrame?.verdict || "N/A"}，更匹配 ${recommended.style}`,
    playbooks: all,
  };
}



path: scripts/lib/questionnaire.mjs
content: export function buildQuestionnaire() {
  return {
    questions: [
      {
        id: "horizon",
        prompt: "您的预期持有周期？",
        options: [
          { id: "short", label: "短期（<1个月）" },
          { id: "mid", label: "中期（1-6个月）" },
          { id: "long", label: "长期（>6个月）" },
        ],
      },
      {
        id: "risk",
        prompt: "您能接受的风险水平？",
        options: [
          { id: "low", label: "低风险（回撤敏感）" },
          { id: "mid", label: "中等风险（均衡）" },
          { id: "high", label: "高风险（追求弹性）" },
        ],
      },
      {
        id: "style",
        prompt: "您偏好的投资风格？",
        options: [
          { id: "value", label: "价值防守" },
          { id: "balanced", label: "均衡配置" },
          { id: "growth", label: "成长进攻" },
          { id: "trading", label: "短线交易" },
        ],
      },
      {
        id: "actionable",
        prompt: "是否需要可执行买卖规则？",
        options: [
          { id: "false", label: "仅研究观点" },
          { id: "true", label: "给出可执行建议（含风控）" },
        ],
      },
    ],
    defaultStyle: "balanced",
    instructions: "请回答上述偏好后重试，或直接传入 --horizon/--risk/--style/--actionable 参数。",
  };
}

export function parsePreferences(args = {}) {
  const hasAny =
    args.horizon != null ||
    args.risk != null ||
    args.style != null ||
    typeof args.actionable === "boolean";
  return {
    hasAny,
    horizon: args.horizon || null,
    risk: args.risk || null,
    style: args.style || null,
    actionable: Boolean(args.actionable),
  };
}



path: scripts/lib/utils.mjs
content: export function clampScore(value) {
  if (!Number.isFinite(value)) return 50;
  return Math.max(0, Math.min(100, Math.round(value)));
}

export function toNumber(value) {
  if (value == null || value === "") return null;
  const n = Number(value);
  return Number.isFinite(n) ? n : null;
}

export function formatNum(value, digits = 2) {
  if (value == null || !Number.isFinite(value)) return "N/A";
  return Number(value).toFixed(digits);
}

export function formatPct(value, digits = 2) {
  if (value == null || !Number.isFinite(value)) return "N/A";
  return `${Number(value).toFixed(digits)}%`;
}

export function formatRatioPct(value, digits = 2) {
  if (value == null || !Number.isFinite(value)) return "N/A";
  const n = Number(value);
  const pct = Math.abs(n) <= 1 ? n * 100 : n;
  return `${pct.toFixed(digits)}%`;
}

export function formatAmountByMarket(value, market, digits = 2) {
  if (value == null || !Number.isFinite(value)) return "N/A";
  const n = Number(value);
  const abs = Math.abs(n);
  if (market === "CN" || market === "HK") {
    return `${(n / 1e8).toFixed(digits)}亿`;
  }
  if (market === "US") {
    if (abs >= 1e9) return `${(n / 1e9).toFixed(digits)}B`;
    if (abs >= 1e6) return `${(n / 1e6).toFixed(digits)}M`;
    return n.toFixed(0);
  }
  if (abs >= 1e9) return `${(n / 1e9).toFixed(digits)}B`;
  if (abs >= 1e6) return `${(n / 1e6).toFixed(digits)}M`;
  return n.toFixed(0);
}

export function parseDateLike(value) {
  if (!value) return null;
  if (typeof value === "number") {
    const date = new Date(value * 1000);
    return Number.isNaN(date.getTime()) ? null : date;
  }
  if (typeof value === "string") {
    const normalized = value.includes("T") ? value : `${value}T00:00:00Z`;
    const date = new Date(normalized);
    return Number.isNaN(date.getTime()) ? null : date;
  }
  return null;
}

export function isStale(value, days = 5) {
  const date = parseDateLike(value);
  if (!date) return false;
  const diffMs = Date.now() - date.getTime();
  return diffMs > days * 24 * 60 * 60 * 1000;
}

export function gradeFromScore(score) {
  if (score >= 85) return "A";
  if (score >= 75) return "B+";
  if (score >= 65) return "B";
  if (score >= 55) return "C+";
  if (score >= 45) return "C";
  return "D";
}

export function normalizeNewlines(text) {
  return String(text || "").replace(/\r\n?/g, "\n");
}

export function maxConsecutiveCharRun(text, ch) {
  const s = String(text || "");
  let max = 0;
  let cur = 0;
  for (let i = 0; i < s.length; i++) {
    if (s[i] === ch) {
      cur += 1;
      if (cur > max) max = cur;
    } else {
      cur = 0;
    }
  }
  return max;
}

export function toFencedCodeBlock(content, info = "text") {
  const body = normalizeNewlines(content);
  const maxTicks = maxConsecutiveCharRun(body, "`");
  const fenceLen = Math.max(3, maxTicks + 1);
  const fence = "`".repeat(fenceLen);
  return `${fence}${info}\n${body}\n${fence}`;
}



path: scripts/stock_copilot_pro.mjs
content: #!/usr/bin/env node

import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildQuestionnaire, parsePreferences } from "./lib/questionnaire.mjs";
import { buildFinancialQuality, buildThesis, buildValuationFrame, buildScorecard as buildScorecardV2, detectChaseRisk as detectChaseRiskV2 } from "./lib/analysis.mjs";
import { buildEventRadarIdeas, clusterEventsByTheme, extractEvents } from "./lib/events.mjs";
import { buildPlaybooks } from "./lib/playbooks.mjs";
import { summarizeQuality as summarizeQualityV2 } from "./lib/data.mjs";
import { formatMarkdown as formatMarkdownV2 } from "./lib/output.mjs";
import {
  getApiKey as qGetApiKey,
  searchTools as qSearchTools,
  executeTool as qExecuteTool,
  resolveToolPayloadSync as qResolveToolPayloadSync,
} from "./lib/infra/qveris-client.mjs";
import { parseCapability as parseCapabilityV2 } from "./lib/data/parser.mjs";
import {
  hasCjk as hasCjkV2,
  normalizeAliasKey as normalizeAliasKeyV2,
  extractTickerFromText as extractTickerFromTextV2,
  inferMarketFromSymbol as inferMarketFromSymbolV2,
  resolveCompanyNameViaQveris as resolveCompanyNameViaQverisV2,
  resolveRequestedSymbol as resolveRequestedSymbolV2,
  normalizeSymbols as normalizeSymbolsV2,
  toThsCode as toThsCodeV2,
} from "./lib/market/resolver.mjs";
import { hasCommand, dispatchCommand } from "./lib/core/router.mjs";
import { formatForChat } from "./lib/output/chat/formatter.mjs";
import { buildDecisionCard } from "./lib/output/decision-card.mjs";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SKILL_ROOT = path.resolve(__dirname, "..");
const EVOLUTION_DIR = path.join(SKILL_ROOT, ".evolution");
const EVOLUTION_FILE = path.join(EVOLUTION_DIR, "tool-evolution.json");
const TOOL_CHAINS_FILE = path.join(SKILL_ROOT, "references", "tool-chains.json");
const SECTOR_BENCHMARKS_FILE = path.join(SKILL_ROOT, "references", "sector-benchmarks.json");
const EVOLUTION_VERSION = 2;
const MAX_TOOLS_TRACKED = 200;
const MAX_MARKETS_PER_TOOL = 8;

const CAPABILITY_QUERIES = {
  quote: "real-time stock quote and historical OHLCV data API",
  fundamentals: "company financial statements and valuation metrics API",
  technicals: "technical indicators RSI MACD moving average API",
  sentiment: "stock news sentiment and market headlines API",
  x_sentiment: "X Twitter stock sentiment API by ticker",
};

const CN_HK_QUERY_HINTS = {
  quote: "ths_ifind.real_time_quotation China and Hong Kong stock real-time quotation API",
  fundamentals:
    "ths_ifind.financial_statements ths_ifind.company_basics China and Hong Kong stock financial statements and company basics API",
  technicals: "ths_ifind.history_quotation China and Hong Kong stock historical quotation API",
};

const X_SENTIMENT_TOOL_PREFIXES = [
  "x_developer.2.tweets.search.recent",
  "qveris_social.x_domain_new_posts",
  "qveris_social.x_domain_hot_topics",
  "qveris_social.x_domain_hot_events",
];
const CAIDAZI_TOOL_PREFIXES = [
  "caidazi.news.query",
  "caidazi.report.query",
  "caidazi.search.hybrid.list",
  "caidazi.search.hybrid_v2.query",
];

const COMPANY_SYMBOL_ALIASES = {
  特变电工: { symbol: "600089.SH", market: "CN" },
  英伟达: { symbol: "NVDA", market: "US" },
  腾讯: { symbol: "0700.HK", market: "HK" },
  腾讯控股: { symbol: "0700.HK", market: "HK" },
  贵州茅台: { symbol: "600519.SH", market: "CN" },
};

function redactSecrets(text) {
  if (!text) return text;
  return String(text)
    .replace(/Bearer\s+[A-Za-z0-9\-\._~\+\/]+=*/gi, "Bearer [REDACTED]")
    .replace(/QVERIS_API_KEY\s*[:=]\s*['"]?[^'"\s]+['"]?/gi, "QVERIS_API_KEY=[REDACTED]")
    .replace(/("?(?:qveris_api_key|authorization)"?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,}]+)/gi, "$1[REDACTED]")
    .replace(/([?&](?:api_key|token|access_token|authorization)=)[^&\s]+/gi, "$1[REDACTED]")
    .replace(/[A-Za-z0-9_-]{24,}\.[A-Za-z0-9._-]{10,}\.[A-Za-z0-9._-]{10,}/g, "[REDACTED_TOKEN]");
}

function isoNow() {
  return new Date().toISOString();
}

function getApiKey() {
  return qGetApiKey();
}

function toNumber(value) {
  if (value == null || value === "") return null;
  const n = Number(value);
  return Number.isFinite(n) ? n : null;
}

function pickFirst(...values) {
  for (const v of values) {
    if (v == null) continue;
    if (typeof v === "string" && v.trim() === "") continue;
    return v;
  }
  return null;
}

function timeoutSignal(timeoutMs) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  return { signal: controller.signal, cleanup: () => clearTimeout(timer) };
}

function emptyEvolutionState() {
  return {
    version: EVOLUTION_VERSION,
    updated_at: isoNow(),
    tools: {},
  };
}

function toInt(value, fallback = 0) {
  const n = Number(value);
  return Number.isInteger(n) ? n : fallback;
}

function sanitizeParamValue(value) {
  if (value == null) return value;
  if (typeof value === "string") {
    const v = redactSecrets(value);
    return v.length > 300 ? `${v.slice(0, 300)}...` : v;
  }
  if (typeof value === "number" || typeof value === "boolean") return value;
  if (Array.isArray(value)) return value.slice(0, 20).map((x) => sanitizeParamValue(x));
  if (typeof value === "object") {
    const out = {};
    for (const [k, v] of Object.entries(value).slice(0, 50)) out[k] = sanitizeParamValue(v);
    return out;
  }
  return String(value);
}

function sanitizeToolRecord(toolId, record) {
  const markets = Array.isArray(record?.markets)
    ? [...new Set(record.markets.filter((x) => typeof x === "string"))].slice(0, MAX_MARKETS_PER_TOOL)
    : [];
  const paramTemplates =
    record?.param_templates && typeof record.param_templates === "object"
      ? Object.fromEntries(
          Object.entries(record.param_templates)
            .filter(([k]) => typeof k === "string")
            .slice(0, MAX_MARKETS_PER_TOOL)
            .map(([k, v]) => [k, sanitizeParamValue(v)]),
        )
      : {};
  return {
    tool_id: String(toolId),
    provider: typeof record?.provider === "string" ? record.provider : pickProvider(toolId),
    first_seen_at: typeof record?.first_seen_at === "string" ? record.first_seen_at : isoNow(),
    last_success_at: typeof record?.last_success_at === "string" ? record.last_success_at : null,
    success_count: Math.max(0, toInt(record?.success_count, 0)),
    fail_count: Math.max(0, toInt(record?.fail_count, 0)),
    avg_elapsed_ms: Math.max(0, toInt(record?.avg_elapsed_ms, 0)),
    markets,
    param_templates: paramTemplates,
    sample_successful_params: sanitizeParamValue(record?.sample_successful_params || null),
    last_error: typeof record?.last_error === "string" ? redactSecrets(record.last_error) : null,
  };
}

function sanitizeEvolutionState(state) {
  const safe = emptyEvolutionState();
  if (!state || typeof state !== "object") {
    return safe;
  }
  const sourceTools =
    state.tools && typeof state.tools === "object"
      ? state.tools
      : state.capability_buckets && typeof state.capability_buckets === "object"
        ? Object.fromEntries(
            Object.values(state.capability_buckets)
              .filter((bucket) => bucket && typeof bucket === "object" && bucket.tools && typeof bucket.tools === "object")
              .flatMap((bucket) => Object.entries(bucket.tools)),
          )
        : {};
  const toolIds = Object.keys(sourceTools).slice(0, MAX_TOOLS_TRACKED);
  for (const toolId of toolIds) {
    if (!toolId || typeof toolId !== "string") continue;
    safe.tools[toolId] = sanitizeToolRecord(toolId, sourceTools[toolId]);
  }
  safe.updated_at = isoNow();
  return safe;
}

function ensureEvolutionToolRecord(state, toolId) {
  if (!state.tools[toolId]) {
    state.tools[toolId] = {
      tool_id: toolId,
      provider: pickProvider(toolId),
      first_seen_at: isoNow(),
      last_success_at: null,
      success_count: 0,
      fail_count: 0,
      avg_elapsed_ms: 0,
      markets: [],
      param_templates: {},
      sample_successful_params: null,
      last_error: null,
    };
  }
  return state.tools[toolId];
}

async function loadEvolutionState() {
  try {
    const content = await fs.readFile(EVOLUTION_FILE, "utf8");
    const parsed = JSON.parse(content);
    return sanitizeEvolutionState(parsed);
  } catch {
    return emptyEvolutionState();
  }
}

async function saveEvolutionState(state) {
  const payload = sanitizeEvolutionState(state);
  await fs.mkdir(EVOLUTION_DIR, { recursive: true });
  await fs.writeFile(EVOLUTION_FILE, JSON.stringify(payload, null, 2), "utf8");
}

function pickProvider(toolId) {
  if (!toolId) return "unknown";
  const first = String(toolId).split(".")[0];
  return first || "unknown";
}

let toolChainsCache = null;
let sectorBenchmarksCache = null;

async function loadToolChains() {
  if (toolChainsCache) return toolChainsCache;
  try {
    const content = await fs.readFile(TOOL_CHAINS_FILE, "utf8");
    toolChainsCache = JSON.parse(content);
  } catch {
    toolChainsCache = {};
  }
  return toolChainsCache;
}

async function loadSectorBenchmarks() {
  if (sectorBenchmarksCache) return sectorBenchmarksCache;
  try {
    const content = await fs.readFile(SECTOR_BENCHMARKS_FILE, "utf8");
    sectorBenchmarksCache = JSON.parse(content);
  } catch {
    sectorBenchmarksCache = { default: { pe_median: 20, pb_median: 2.5, roe_benchmark: 12, debt_ratio_warn: 0.6 } };
  }
  return sectorBenchmarksCache;
}

function templateContextVars(context = {}) {
  const vars = {};
  const push = (key, value) => {
    if (value == null || value === "") return;
    vars[key] = value;
    vars[String(value)] = `{${key}}`;
  };
  push("symbol", context.symbol);
  push("thsCode", context.thsCode);
  push("base", context.base);
  push("market", context.market);
  push("keyword", context.keyword);
  push("caidaziTicker", context.caidaziTicker);
  push("report.year", context.report?.year);
  push("report.period", context.report?.period);
  push("recentStart", context.recentStart);
  push("history.startdate", context.history?.startdate);
  push("history.enddate", context.history?.enddate);
  return vars;
}

function createTemplateFromParams(params, context = {}) {
  const vars = templateContextVars(context);
  const walk = (value) => {
    if (value == null) return value;
    if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
      const direct = vars[String(value)];
      return direct || value;
    }
    if (Array.isArray(value)) return value.map((x) => walk(x));
    if (typeof value === "object") {
      const out = {};
      for (const [k, v] of Object.entries(value)) out[k] = walk(v);
      return out;
    }
    return value;
  };
  return walk(params);
}

function readObjectPath(source, pathExpr) {
  const parts = String(pathExpr || "").split(".");
  let cur = source;
  for (const p of parts) {
    if (!cur || typeof cur !== "object" || !(p in cur)) return null;
    cur = cur[p];
  }
  return cur;
}

function interpolateTemplate(template, context = {}) {
  const walk = (value) => {
    if (value == null) return value;
    if (typeof value === "string") {
      const m = value.match(/^\{([a-zA-Z0-9_.]+)\}$/);
      if (m?.[1]) {
        const found = readObjectPath(context, m[1]);
        return found == null ? value : found;
      }
      return value;
    }
    if (Array.isArray(value)) return value.map((x) => walk(x));
    if (typeof value === "object") {
      const out = {};
      for (const [k, v] of Object.entries(value)) out[k] = walk(v);
      return out;
    }
    return value;
  };
  return walk(template);
}

function getEvolutionTemplate(state, toolId, market) {
  const record = state?.tools?.[toolId];
  if (!record?.param_templates) return null;
  const template = record.param_templates[market] || record.param_templates.GLOBAL || null;
  if (!template || typeof template !== "object") return template;
  const dynamicKeys = new Set(["startdate", "enddate", "publishTimeStart", "publishDateStart"]);
  const hasStaticDate = (value) => {
    if (!value || typeof value !== "object") return false;
    for (const [k, v] of Object.entries(value)) {
      if (dynamicKeys.has(k) && typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v)) return true;
      if (typeof v === "object" && hasStaticDate(v)) return true;
    }
    return false;
  };
  return hasStaticDate(template) ? null : template;
}

function updateEvolutionOnAttempt(state, meta) {
  const { market, toolId, elapsedMs, success, params, paramContext, errorMessage, newlyLearnedCollector } = meta;
  if (!toolId) return;
  const record = ensureEvolutionToolRecord(state, toolId);
  if (market && !record.markets.includes(market)) record.markets.push(market);
  if (success) {
    record.success_count += 1;
    record.last_success_at = isoNow();
    const prev = Number(record.avg_elapsed_ms || 0);
    const n = record.success_count;
    record.avg_elapsed_ms = n === 1 ? elapsedMs : Math.round((prev * (n - 1) + elapsedMs) / n);
    record.sample_successful_params = sanitizeParamValue(params);
    const template = createTemplateFromParams(params, paramContext);
    record.param_templates[market || "GLOBAL"] = sanitizeParamValue(template);
    if (newlyLearnedCollector && n === 1) newlyLearnedCollector.push(toolId);
    record.last_error = null;
  } else {
    record.fail_count += 1;
    if (errorMessage) record.last_error = redactSecrets(errorMessage);
  }
  state.updated_at = isoNow();
}

async function searchTools(query, limit, timeoutMs) {
  return qSearchTools(query, limit, timeoutMs);
}

async function executeTool(toolId, searchId, parameters, maxResponseSize, timeoutMs) {
  return qExecuteTool(toolId, searchId, parameters, maxResponseSize, timeoutMs);
}

function isToolCallSuccessful(result) {
  if (!result || typeof result !== "object") return false;
  if (result.success === false) return false;
  const payload = result?.result ?? result;
  const data = payload?.data ?? payload;
  if (data?.status === "error") return false;
  if (typeof data?.code === "number" && data.code >= 400) return false;
  if (typeof data?.["Error Message"] === "string") return false;
  if (typeof data?.Information === "string" && data.Information.toLowerCase().includes("burst pattern")) return false;
  return true;
}

function scoreTool(tool) {
  const stats = tool?.stats || {};
  const successRate = toNumber(stats.success_rate) ?? 0;
  const latency = toNumber(stats.avg_execution_time_ms);
  const latencyScore = latency && latency > 0 ? 1 / (1 + latency) : 0;
  const hasExamples = tool?.examples?.sample_parameters ? 0.03 : 0;
  return successRate * 0.8 + latencyScore * 0.17 + hasExamples;
}

function rankTools(results) {
  return [...(results || [])]
    .sort((a, b) => scoreTool(b) - scoreTool(a))
    .map((tool) => ({
      ...tool,
      _score: scoreTool(tool),
    }));
}

function summarizeTool(tool) {
  if (!tool || typeof tool !== "object") return null;
  return {
    tool_id: tool.tool_id ?? null,
    name: tool.name ?? null,
    stats: tool.stats ?? null,
    _fromEvolution: Boolean(tool._fromEvolution),
  };
}

function hasCjk(text) {
  return hasCjkV2(text);
}

function normalizeAliasKey(value) {
  return normalizeAliasKeyV2(value);
}

function extractTickerFromText(value) {
  return extractTickerFromTextV2(value);
}

function inferMarketFromSymbol(value) {
  return inferMarketFromSymbolV2(value);
}

// Dynamic company name resolution via QVeris (ths_ifind.code_converter)
const THS_CODE_CONVERTER_TOOL_ID = "ths_ifind.code_converter.v1";
const CODE_CONVERTER_TIMEOUT_MS = 5000;

function splitCandidateCodes(rawValue) {
  const raw = String(rawValue || "").trim();
  if (!raw) return [];
  return raw
    .split(/[,\s;；|]+/)
    .map((x) => x.trim().toUpperCase())
    .filter(Boolean);
}

function isCodeMatchingMarket(code, market) {
  const c = String(code || "").toUpperCase();
  if (!c) return false;
  if (market === "HK") return /\.HK$/.test(c) || /^\d{4,5}$/.test(c);
  if (market === "CN") return /\.(SH|SZ|SS)$/.test(c) || /^\d{6}$/.test(c);
  if (market === "US") return /^[A-Z]{1,6}(\.US)?$/.test(c);
  return true;
}

function scoreCandidateCode(code, preferredMarket) {
  const c = String(code || "").toUpperCase();
  if (!c) return -999;
  let score = 0;
  if (isCodeMatchingMarket(c, preferredMarket)) score += 100;
  if (preferredMarket === "HK") {
    if (/^\d{4,5}\.HK$/.test(c)) score += 40;
    if (/^\d{4,5}$/.test(c)) score += 20;
  } else if (preferredMarket === "CN") {
    if (/^\d{6}\.(SH|SZ|SS)$/.test(c)) score += 40;
    if (/^\d{6}$/.test(c)) score += 20;
  } else if (preferredMarket === "US") {
    if (/^[A-Z]{1,6}$/.test(c)) score += 35;
    if (/^[A-Z]{1,6}\.US$/.test(c)) score += 25;
  } else if (/\.(HK|SH|SZ|SS|US)$/.test(c) || /^[A-Z]{1,6}$/.test(c)) {
    score += 10;
  }
  if (/\.PQ$/.test(c)) score -= 30;
  return score;
}

function normalizeResolvedCode(code, preferredMarket) {
  const c = String(code || "").toUpperCase();
  if (!c) return null;
  if (preferredMarket === "HK" && /^\d{4,5}$/.test(c)) return `${c.padStart(4, "0")}.HK`;
  if (preferredMarket === "CN" && /^\d{6}$/.test(c)) return c.startsWith("6") ? `${c}.SH` : `${c}.SZ`;
  if (preferredMarket === "US" && /^[A-Z]{1,6}\.US$/.test(c)) return c.replace(/\.US$/, "");
  return c;
}

function generateResolverNameVariants(companyName, preferredMarket = "GLOBAL") {
  const base = String(companyName || "").trim();
  if (!base) return [];
  const variants = new Set([base]);
  const normalized = base
    .replace(/(集团|控股|股份|有限责任公司|有限公司)$/g, "")
    .trim();
  if (normalized && normalized !== base) variants.add(normalized);
  if (preferredMarket === "HK") {
    const seed = [...variants];
    for (const v of seed) {
      variants.add(`${v}-W`);
    }
  }
  return [...variants];
}

function extractBestCodeFromConverterRows(rows, preferredMarket) {
  const candidates = [];
  for (const row of rows || []) {
    const thscodeValues = row?.table?.thscode;
    if (!Array.isArray(thscodeValues)) continue;
    for (const item of thscodeValues) {
      for (const code of splitCandidateCodes(item)) {
        candidates.push(code);
      }
    }
  }
  if (candidates.length === 0) return null;
  const unique = [...new Set(candidates)];
  unique.sort((a, b) => scoreCandidateCode(b, preferredMarket) - scoreCandidateCode(a, preferredMarket));
  return normalizeResolvedCode(unique[0], preferredMarket);
}

async function resolveCompanyNameViaQveris(companyName, preferredMarket = "GLOBAL") {
  return resolveCompanyNameViaQverisV2(companyName, preferredMarket);
}

async function resolveRequestedSymbol(input, preferredMarket = "GLOBAL") {
  return resolveRequestedSymbolV2(input, preferredMarket, COMPANY_SYMBOL_ALIASES);
}

function prioritizeCandidatesByMarket(candidates, capability, market) {
  const list = [...(candidates || [])];
  const isCnHkCore = ["CN", "HK"].includes(market) && ["quote", "fundamentals", "technicals"].includes(capability);
  if (!isCnHkCore) return list;
  const native = list.filter((t) => String(t?.tool_id || "").startsWith("ths_ifind."));
  if (native.length === 0) return list;
  const others = list.filter((t) => !String(t?.tool_id || "").startsWith("ths_ifind."));
  return [...native, ...others];
}

function isCaidaziTool(toolId) {
  const id = String(toolId || "");
  return CAIDAZI_TOOL_PREFIXES.some((prefix) => id.startsWith(prefix));
}

function caidaziPriority(toolId) {
  const id = String(toolId || "");
  if (id.startsWith("caidazi.search.hybrid_v2.query")) return 4;
  if (id.startsWith("caidazi.news.query")) return 3;
  if (id.startsWith("caidazi.report.query")) return 2;
  if (id.startsWith("caidazi.search.hybrid.list")) return 1;
  return 0;
}

function thsFundamentalPriority(toolId) {
  const id = String(toolId || "");
  if (id.startsWith("ths_ifind.financial_statements")) return 5;
  if (id.startsWith("ths_ifind.income_statement")) return 4;
  if (id.startsWith("ths_ifind.balance_sheet")) return 3;
  if (id.startsWith("ths_ifind.cash_flow_statement")) return 2;
  if (id.startsWith("ths_ifind.company_basics")) return 1;
  return 0;
}

function isToolCompatibleForCapability(toolId, capability, market) {
  const id = String(toolId || "");
  if (!id) return false;
  if (capability === "quote") {
    if (id.startsWith("ths_ifind.") && !id.startsWith("ths_ifind.real_time_quotation")) return false;
    return true;
  }
  if (capability === "fundamentals") {
    if (
      id.startsWith("ths_ifind.") &&
      !id.startsWith("ths_ifind.company_basics") &&
      !id.startsWith("ths_ifind.financial_statements") &&
      !id.startsWith("ths_ifind.income_statement") &&
      !id.startsWith("ths_ifind.balance_sheet") &&
      !id.startsWith("ths_ifind.cash_flow_statement")
    ) {
      return false;
    }
    return true;
  }
  if (capability === "technicals") {
    if (id.startsWith("ths_ifind.") && !id.startsWith("ths_ifind.history_quotation")) return false;
    return true;
  }
  if (capability === "x_sentiment") {
    return X_SENTIMENT_TOOL_PREFIXES.some((prefix) => id.startsWith(prefix));
  }
  if (capability === "sentiment") {
    return !id.startsWith("x_developer.");
  }
  if (["CN", "HK"].includes(market) && ["quote", "fundamentals", "technicals"].includes(capability)) {
    if (id.startsWith("ths_ifind.")) return true;
  }
  return true;
}

function marketCapabilityBoost(tool, capability, market) {
  const id = String(tool?.tool_id || "");
  if (["CN", "HK"].includes(market) && ["quote", "fundamentals", "technicals"].includes(capability)) {
    if (id.startsWith("ths_ifind.")) return 0.3;
    if (id.startsWith("alphavantage.") || id.startsWith("twelvedata.") || id.startsWith("finnhub_io_api.")) return -0.08;
  }
  if (["CN", "HK"].includes(market) && capability === "fundamentals") {
    if (id.startsWith("ths_ifind.financial_statements") || id.startsWith("ths_ifind.income_statement")) return 0.2;
    if (id.startsWith("ths_ifind.balance_sheet")) return 0.14;
    if (id.startsWith("ths_ifind.cash_flow_statement")) return 0.1;
    if (id.startsWith("ths_ifind.company_basics")) return 0.08;
  }
  if (market === "US" && ["quote", "fundamentals", "technicals"].includes(capability)) {
    if (id.startsWith("alphavantage.") || id.startsWith("twelvedata.") || id.startsWith("finnhub_io_api.")) return 0.08;
  }
  if (["CN", "HK"].includes(market) && capability === "sentiment") {
    if (isCaidaziTool(id)) return 0.28;
    if (id.startsWith("finnhub.") || id.startsWith("alphavantage.")) return -0.05;
  }
  return 0;
}

function normalizeSymbols(input, market) {
  return normalizeSymbolsV2(input, market);
}

function toThsCode(symbol, market) {
  return toThsCodeV2(symbol, market);
}

function parseDateLike(value) {
  if (!value) return null;
  if (typeof value === "number") {
    const date = new Date(value * 1000);
    return Number.isNaN(date.getTime()) ? null : date;
  }
  if (typeof value === "string") {
    const normalized = value.includes("T") ? value : `${value}T00:00:00Z`;
    const date = new Date(normalized);
    return Number.isNaN(date.getTime()) ? null : date;
  }
  return null;
}

function isStale(value, days = 5) {
  const date = parseDateLike(value);
  if (!date) return false;
  const diffMs = Date.now() - date.getTime();
  return diffMs > days * 24 * 60 * 60 * 1000;
}

function pickQuoteData(raw) {
  const data = raw?.result?.data || raw?.data || raw || {};
  if (Array.isArray(data) && Array.isArray(data[0]) && data[0][0]) {
    const row = data[0][0];
    return {
      symbol: row.thscode ?? null,
      price: toNumber(row.latest),
      open: toNumber(row.open),
      high: toNumber(row.high),
      low: toNumber(row.low),
      volume: toNumber(row.volume),
      percentChange: toNumber(row.changeRatio),
      marketCap: toNumber(row.mv ?? row.totalCapital),
      pe: toNumber(row.pe_ttm ?? row.pe),
      pb: toNumber(row.pb ?? row.pbr_lf),
      timestamp: row.time ?? row.tradeDate ?? null,
      raw: row,
    };
  }
  return {
    symbol: data.symbol ?? data.Symbol ?? null,
    price: toNumber(data.close ?? data.price ?? data.latestPrice),
    open: toNumber(data.open),
    high: toNumber(data.high),
    low: toNumber(data.low),
    volume: toNumber(data.volume),
    percentChange: toNumber(data.percent_change ?? data.changePercent),
    marketCap: toNumber(data.marketCap ?? data.mv ?? data.totalCapital),
    pe: toNumber(data.pe_ttm ?? data.pe ?? data.TrailingPE),
    pb: toNumber(data.pb ?? data.pbr_lf ?? data.PriceToBookRatio),
    timestamp: data.datetime ?? data.timestamp ?? data["07. latest trading day"] ?? null,
    raw: data,
  };
}

function pickFundamentalData(raw) {
  const data = raw?.result?.data || raw?.data || raw || {};
  if (Array.isArray(data) && Array.isArray(data[0]) && data[0][0]) {
    const row = data[0][0];
    const statementType = row.statement_type || row.statementType || null;
    const period = row.time || row.end_date || row.report_date || null;
    const tagsRaw = pickFirst(
      row.ths_the_ths_concept_index_stock,
      row.ths_concept_stock,
      row.ths_concept_list_stock,
      row.concept,
      row.tags,
      row.tag_list,
      row.ths_tags_stock,
    );
    return {
      symbol: row.ths_thscode_stock ?? row.thscode ?? null,
      name: row.ths_corp_cn_name_stock ?? row.ths_short_name_stock ?? row.short_name ?? null,
      industry: pickFirst(
        row.ths_the_ths_industry_stock,
        row.ths_industry_stock,
        row.ths_industry_name_stock,
        row.ths_sw_industry_stock,
        row.industry,
        row.industry_name,
      ),
      mainBusiness: pickFirst(
        row.ths_main_businuess_stock,
        row.ths_main_business_stock,
        row.ths_business_scope_stock,
        row.ths_mo_product_name_stock,
        row.main_business,
        row.business_scope,
      ),
      tags: Array.isArray(tagsRaw)
        ? tagsRaw.filter(Boolean)
        : typeof tagsRaw === "string"
          ? tagsRaw
              .split(/[，,;；|]/)
              .map((x) => x.trim())
              .filter(Boolean)
          : null,
      marketCap: null,
      pe: null,
      forwardPe: null,
      pb: null,
      ps: null,
      profitMargin: null,
      revenueGrowthYoy: null,
      earningsGrowthYoy: null,
      week52High: null,
      week52Low: null,
      latestQuarter: period,
      statementType,
      reportPeriod: period,
      revenue: toNumber(row.ths_revenue_stock ?? row.ths_operating_total_revenue_stock),
      netProfit: toNumber(row.ths_np_atoopc_stock ?? row.ths_np_stock),
      totalAssets: toNumber(row.ths_total_assets_stock),
      totalLiabilities: toNumber(row.ths_total_liab_stock),
      operatingCashflow: toNumber(row.ths_ncf_from_oa_stock),
      raw: row,
    };
  }
  return {
    symbol: data.Symbol ?? data.symbol ?? null,
    name: data.Name ?? data.name ?? null,
    industry: pickFirst(data.Industry, data.industry, data.industry_name),
    mainBusiness: pickFirst(data.BusinessDescription, data.business_description, data.main_business),
    tags: Array.isArray(data.tags)
      ? data.tags
      : typeof (data.concept ?? data.ths_the_ths_concept_index_stock) === "string"
        ? String(data.concept ?? data.ths_the_ths_concept_index_stock)
            .split(/[，,;；|]/)
            .map((x) => x.trim())
            .filter(Boolean)
        : null,
    marketCap: toNumber(data.MarketCapitalization ?? data.marketCap),
    pe: toNumber(data.PERatio ?? data.pe ?? data.TrailingPE),
    forwardPe: toNumber(data.ForwardPE),
    pb: toNumber(data.PriceToBookRatio),
    ps: toNumber(data.PriceToSalesRatioTTM),
    profitMargin: toNumber(data.ProfitMargin),
    revenueGrowthYoy: toNumber(data.QuarterlyRevenueGrowthYOY),
    earningsGrowthYoy: toNumber(data.QuarterlyEarningsGrowthYOY),
    week52High: toNumber(data["52WeekHigh"] ?? data.fifty_two_week?.high),
    week52Low: toNumber(data["52WeekLow"] ?? data.fifty_two_week?.low),
    latestQuarter: data.LatestQuarter ?? null,
    raw: data,
  };
}

function pickTechnicalData(raw) {
  const result = raw?.result || raw || {};
  const data = result?.data || result;
  if (Array.isArray(data) && Array.isArray(data[0]) && data[0][0]) {
    const rows = data[0];
    const row = rows[rows.length - 1];
    const prev5Index = Math.max(0, rows.length - 6);
    const prev5 = rows[prev5Index] || null;
    const closeNow = toNumber(row.close);
    const closePrev5 = toNumber(prev5?.close);
    const change5d =
      closeNow != null && closePrev5 != null && closePrev5 !== 0 ? ((closeNow - closePrev5) / closePrev5) * 100 : null;
    const currentVolume = toNumber(row.volume);
    const volumes = rows
      .slice(Math.max(0, rows.length - 6), rows.length - 1)
      .map((x) => toNumber(x.volume))
      .filter((x) => x != null);
    const avgPrev5Volume =
      volumes.length > 0 ? volumes.reduce((sum, x) => sum + x, 0) / volumes.length : null;
    const volumeRatio =
      currentVolume != null && avgPrev5Volume != null && avgPrev5Volume > 0 ? currentVolume / avgPrev5Volume : null;
    return {
      latestDate: row.time ?? null,
      rsi: null,
      close: closeNow,
      changeRatio: toNumber(row.changeRatio),
      change5d,
      volumeRatio,
      avgPrev5Volume,
      raw: row,
    };
  }
  if (data?.["Technical Analysis: RSI"]) {
    const entries = Object.entries(data["Technical Analysis: RSI"]);
    if (entries.length > 0) {
      const [date, value] = entries[0];
      return {
        latestDate: date,
        rsi: toNumber(value?.RSI),
        raw: data,
      };
    }
  }
  return {
    latestDate: null,
    rsi: null,
    raw: data,
  };
}

function pickSentimentData(raw) {
  const payload = raw?.result || raw || {};
  const data = payload?.data || payload;
  const content = qResolveToolPayloadSync(raw).content ?? data;
  const hits = content?.data?.hits || content?.hits || [];
  if (Array.isArray(hits)) {
    const latest = hits[0]?.source || hits[0] || null;
    const sentimentScore = latest?.newsSentiment?.[0]?.sentimentScore ?? latest?.weChatSentiment?.[0]?.sentimentScore ?? null;
    const sourceLabel = latest?.type || latest?.sourceName || latest?.siteName || null;
    return {
      itemCount: hits.length,
      latestHeadline: latest?.title ?? null,
      latestTime: latest?.publishTime ?? latest?.effectiveTime ?? null,
      latestTickerSentiment: sentimentScore,
      sourceLabel,
      fullContentFileUrl: data?.full_content_file_url ?? null,
      raw: content,
    };
  }
  if (Array.isArray(content)) {
    const latest = content[0] || null;
    return {
      itemCount: content.length,
      latestHeadline: latest?.headline ?? null,
      latestTime: latest?.datetime ?? null,
      latestTickerSentiment: null,
      fullContentFileUrl: data?.full_content_file_url ?? null,
      raw: content,
    };
  }
  const feed = content?.feed || [];
  const latest = feed[0] || null;
  return {
    itemCount: toNumber(content?.items) ?? feed.length,
    latestHeadline: latest?.title ?? null,
    latestTime: latest?.time_published ?? null,
    latestTickerSentiment: latest?.ticker_sentiment?.[0]?.ticker_sentiment_label ?? null,
    fullContentFileUrl: data?.full_content_file_url ?? null,
    raw: content,
  };
}

function pickXSentimentData(raw) {
  const payload = raw?.result || raw || {};
  const data = payload?.data ?? payload;
  const content = qResolveToolPayloadSync(raw).content ?? data;

  let posts = [];
  if (Array.isArray(content)) posts = content;
  else if (Array.isArray(content?.data)) posts = content.data;
  else if (Array.isArray(content?.posts)) posts = content.posts;
  else if (Array.isArray(content?.results)) posts = content.results;

  const first = posts[0] || null;
  return {
    itemCount: posts.length || toNumber(content?.total) || 0,
    topPostText: first?.text ?? first?.full_text ?? first?.title ?? first?.headline ?? null,
    topPostTime: first?.created_at ?? first?.time ?? first?.datetime ?? null,
    sourceMode: Array.isArray(content) ? "fallback" : "direct",
    fullContentFileUrl: data?.full_content_file_url ?? null,
    raw: content,
  };
}

async function executeWithFallback(candidates, searchId, paramBuilder, options, executionMeta = {}) {
  const attempts = [];
  const maxAttempts = options.maxAttemptsPerPhase || 3;
  const successValidator = executionMeta?.successValidator;
  for (const tool of candidates.slice(0, maxAttempts)) {
    const built = paramBuilder(tool);
    const params = built?.params ?? built;
    const paramContext = built?.paramContext ?? null;
    try {
      const sid = searchId;
      const startedAt = Date.now();
      const result = await executeTool(tool.tool_id, sid, params, options.maxSize, options.timeoutMs);
      const ok = isToolCallSuccessful(result);
      const validatedOk = ok && (typeof successValidator === "function" ? successValidator(result) : true);
      const elapsed = Date.now() - startedAt;
      attempts.push({ toolId: tool.tool_id, ok: validatedOk, params, elapsed_ms: elapsed });
      if (options.evolutionState) {
        updateEvolutionOnAttempt(options.evolutionState, {
          ...executionMeta,
          toolId: tool.tool_id,
          elapsedMs: elapsed,
          success: validatedOk,
          params,
          paramContext,
          newlyLearnedCollector: options.newlyLearnedTools,
        });
      }
      if (validatedOk) {
        return { success: true, selectedTool: summarizeTool(tool), attempts, successfulResult: result };
      }
    } catch (error) {
      attempts.push({ toolId: tool.tool_id, ok: false, error: redactSecrets(error.message), params });
      if (options.evolutionState) {
        updateEvolutionOnAttempt(options.evolutionState, {
          ...executionMeta,
          toolId: tool.tool_id,
          elapsedMs: 0,
          success: false,
          params,
          paramContext,
          errorMessage: error.message,
          newlyLearnedCollector: options.newlyLearnedTools,
        });
      }
    }
  }
  return { success: false, selectedTool: null, attempts };
}

function summarizeQuality(payload) {
  const warnings = [];
  const quote = payload.quote?.parsed;
  const fundamentals = payload.fundamentals?.parsed;
  const technicals = payload.technicals?.parsed;
  const sentiment = payload.sentiment?.parsed;

  if (!quote?.price) warnings.push("行情缺少最新价格字段");
  if (!fundamentals?.pe && !fundamentals?.marketCap) warnings.push("基本面关键估值字段缺失");
  if (technicals && technicals.rsi == null && technicals.changeRatio == null) warnings.push("技术指标缺少 RSI/趋势字段");
  if (sentiment && !sentiment.itemCount) warnings.push("情绪数据缺少新闻条目");

  if (quote?.timestamp && typeof quote.timestamp === "string" && quote.timestamp.length < 8) {
    warnings.push("行情时间戳格式异常");
  }
  if (isStale(quote?.timestamp, 5)) warnings.push("行情数据可能已过期（超过 5 天）");
  if (isStale(fundamentals?.latestQuarter, 220)) warnings.push("基本面季度数据较旧");

  if (quote?.price != null && fundamentals?.week52High != null && quote.price > fundamentals.week52High * 1.08) {
    warnings.push("行情价格明显高于 52 周高点，可能存在跨源不一致");
  }
  if (quote?.price != null && fundamentals?.week52Low != null && quote.price < fundamentals.week52Low * 0.92) {
    warnings.push("行情价格明显低于 52 周低点，可能存在跨源不一致");
  }

  let confidence = "high";
  if (warnings.length >= 2) confidence = "medium";
  if (warnings.length >= 4) confidence = "low";
  return { confidence, warnings };
}

function isParsedDataUsable(capability, parsed) {
  if (!parsed || typeof parsed !== "object") return false;
  if (capability === "quote") {
    return parsed.price != null || parsed.symbol != null || parsed.timestamp != null;
  }
  if (capability === "fundamentals") {
    return (
      parsed.name != null ||
      parsed.marketCap != null ||
      parsed.pe != null ||
      parsed.revenue != null ||
      parsed.netProfit != null ||
      parsed.totalAssets != null ||
      parsed.operatingCashflow != null
    );
  }
  if (capability === "technicals") {
    return parsed.rsi != null || parsed.changeRatio != null || parsed.close != null;
  }
  if (capability === "sentiment") {
    return parsed.itemCount != null;
  }
  if (capability === "x_sentiment") {
    return parsed.itemCount != null;
  }
  return true;
}

function clampScore(value) {
  if (!Number.isFinite(value)) return 50;
  return Math.max(0, Math.min(100, Math.round(value)));
}

function formatNum(value, digits = 2) {
  if (value == null || !Number.isFinite(value)) return "N/A";
  return Number(value).toFixed(digits);
}

function formatAmountByMarket(value, market, digits = 2) {
  if (value == null || !Number.isFinite(value)) return "N/A";
  const n = Number(value);
  const abs = Math.abs(n);
  if (market === "CN" || market === "HK") {
    return `${(n / 1e8).toFixed(digits)}亿`;
  }
  if (market === "US") {
    if (abs >= 1e9) return `${(n / 1e9).toFixed(digits)}B`;
    if (abs >= 1e6) return `${(n / 1e6).toFixed(digits)}M`;
    return n.toFixed(0);
  }
  if (abs >= 1e9) return `${(n / 1e9).toFixed(digits)}B`;
  if (abs >= 1e6) return `${(n / 1e6).toFixed(digits)}M`;
  return n.toFixed(0);
}

function formatPct(value, digits = 2) {
  if (value == null || !Number.isFinite(value)) return "N/A";
  return `${Number(value).toFixed(digits)}%`;
}

function formatRatioPct(value, digits = 2) {
  if (value == null || !Number.isFinite(value)) return "N/A";
  const n = Number(value);
  const pct = Math.abs(n) <= 1 ? n * 100 : n;
  return `${pct.toFixed(digits)}%`;
}

function formatFieldValueByKey(key, value, market) {
  if (value == null) return "N/A";
  if (typeof value === "number") {
    const amountFields = new Set([
      "marketCap",
      "revenue",
      "netProfit",
      "totalAssets",
      "totalLiabilities",
      "operatingCashflow",
      "amount",
      "latestAmount",
      "floatCapitalOfAShares",
      "floatCapitalOfBShares",
      "totalCapital",
      "mv",
    ]);
    const percentFields = new Set([
      "percentChange",
      "changeRatio",
      "change5d",
      "turnoverRatio",
    ]);
    const ratioPercentFields = new Set([
      "profitMargin",
      "revenueGrowthYoy",
      "earningsGrowthYoy",
    ]);
    if (amountFields.has(key)) return formatAmountByMarket(value, market);
    if (percentFields.has(key)) return formatPct(value);
    if (ratioPercentFields.has(key)) return formatRatioPct(value);
    return formatNum(value);
  }
  if (typeof value === "boolean") return value ? "true" : "false";
  if (Array.isArray(value)) return value.join(", ");
  if (typeof value === "object") return JSON.stringify(value);
  return String(value);
}

function appendFieldDetails(lines, sectionTitle, payload, market, excludedKeys = []) {
  if (!payload || typeof payload !== "object") return;
  const keys = Object.keys(payload)
    .filter((k) => !excludedKeys.includes(k))
    .filter((k) => payload[k] != null && payload[k] !== "");
  if (keys.length === 0) return;
  lines.push(sectionTitle);
  for (const key of keys) {
    lines.push(`- ${key}: ${formatFieldValueByKey(key, payload[key], market)}`);
  }
  lines.push("");
}

function appendRawSnapshot(lines, sectionTitle, raw, market, limit = 25) {
  if (!raw || typeof raw !== "object") return;
  const keys = Object.keys(raw)
    .filter((k) => raw[k] != null && raw[k] !== "" && (typeof raw[k] !== "object" || Array.isArray(raw[k])))
    .slice(0, limit);
  if (keys.length === 0) return;
  lines.push(sectionTitle);
  for (const key of keys) {
    lines.push(`- ${key}: ${formatFieldValueByKey(key, raw[key], market)}`);
  }
  lines.push("");
}

function gradeFromScore(score) {
  if (score >= 85) return "A";
  if (score >= 75) return "B+";
  if (score >= 65) return "B";
  if (score >= 55) return "C+";
  if (score >= 45) return "C";
  return "D";
}

function pickSectorBenchmarks(benchmarks, market) {
  if (market === "CN" || market === "HK") return benchmarks.electrical_equipment || benchmarks.default;
  if (market === "US") return benchmarks.semiconductor || benchmarks.default;
  return benchmarks.default;
}

function buildScorecard(payload, benchmarks, market) {
  const q = payload.quote?.parsed || {};
  const f = payload.fundamentals?.parsed || {};
  const t = payload.technicals?.parsed || {};
  const s = payload.sentiment?.parsed || {};
  const x = payload.x_sentiment?.parsed || {};
  const b = pickSectorBenchmarks(benchmarks, market);

  let valuation = 50;
  if (f.pe != null && b.pe_median) {
    const ratio = f.pe / b.pe_median;
    if (ratio <= 0.7) valuation += 25;
    else if (ratio <= 0.9) valuation += 15;
    else if (ratio <= 1.1) valuation += 5;
    else if (ratio >= 1.5) valuation -= 20;
    else valuation -= 8;
  }
  if (f.pb != null && b.pb_median) {
    const ratio = f.pb / b.pb_median;
    if (ratio <= 0.7) valuation += 18;
    else if (ratio <= 0.9) valuation += 10;
    else if (ratio <= 1.1) valuation += 3;
    else if (ratio >= 1.6) valuation -= 18;
    else valuation -= 8;
  }

  let quality = 50;
  const margin = f.profitMargin != null ? f.profitMargin : f.revenue && f.netProfit ? f.netProfit / f.revenue : null;
  if (margin != null && b.profit_margin_benchmark != null) quality += margin >= b.profit_margin_benchmark ? 12 : -8;
  if (f.totalAssets != null && f.totalLiabilities != null && f.totalAssets > 0) {
    const debtRatio = f.totalLiabilities / f.totalAssets;
    quality += debtRatio <= (b.debt_ratio_warn ?? 0.6) ? 12 : -10;
  }
  if (f.operatingCashflow != null) quality += f.operatingCashflow > 0 ? 15 : -12;
  if (f.netProfit != null && f.totalAssets != null && f.totalLiabilities != null) {
    const equity = f.totalAssets - f.totalLiabilities;
    if (equity > 0) {
      const roe = (f.netProfit / equity) * 100;
      quality += roe >= (b.roe_benchmark ?? 12) ? 10 : -6;
    }
  }

  let growth = 50;
  if (f.revenueGrowthYoy != null) {
    if (f.revenueGrowthYoy >= 20) growth += 22;
    else if (f.revenueGrowthYoy >= 10) growth += 14;
    else if (f.revenueGrowthYoy >= 0) growth += 6;
    else growth -= 12;
  }
  if (f.earningsGrowthYoy != null) growth += f.earningsGrowthYoy >= 0 ? 8 : -8;

  let technicals = 50;
  if (t.rsi != null) {
    if (t.rsi >= 40 && t.rsi <= 65) technicals += 12;
    else if (t.rsi > 70) technicals -= 8;
    else if (t.rsi < 30) technicals += 5;
  }
  if (t.changeRatio != null) technicals += t.changeRatio > 0 ? 6 : -4;
  if (t.change5d != null) technicals += t.change5d > 0 ? 8 : -6;

  let sentiment = 50;
  if (s.itemCount != null) sentiment += Math.min(10, s.itemCount * 2);
  if (s.latestTickerSentiment != null && typeof s.latestTickerSentiment === "number") sentiment += s.latestTickerSentiment > 0 ? 8 : -6;
  if (x.itemCount != null) sentiment += Math.min(8, x.itemCount);

  const weights = { valuation: 0.3, quality: 0.25, growth: 0.2, technicals: 0.15, sentiment: 0.1 };
  const score = {
    valuation: clampScore(valuation),
    quality: clampScore(quality),
    growth: clampScore(growth),
    technicals: clampScore(technicals),
    sentiment: clampScore(sentiment),
  };
  const composite = clampScore(
    score.valuation * weights.valuation +
      score.quality * weights.quality +
      score.growth * weights.growth +
      score.technicals * weights.technicals +
      score.sentiment * weights.sentiment,
  );

  return {
    weights,
    score,
    composite,
    grade: gradeFromScore(composite),
  };
}

function calcSafetyMargin(payload, scorecard, benchmarks, market) {
  const q = payload.quote?.parsed || {};
  const f = payload.fundamentals?.parsed || {};
  const b = pickSectorBenchmarks(benchmarks, market);
  const currentPrice = q.price;
  if (currentPrice == null || currentPrice <= 0) {
    return { margin: null, verdict: "无法计算（缺少价格）" };
  }
  const currentPe = f.pe;
  if (currentPe == null || currentPe <= 0 || !b.pe_median) {
    return { margin: null, verdict: "无法计算（缺少PE或行业基准）" };
  }
  const intrinsic = currentPrice * (b.pe_median / currentPe);
  const conservative = intrinsic * 0.8;
  const margin = conservative > 0 ? ((conservative - currentPrice) / conservative) * 100 : null;
  const verdict = margin == null ? "无法判断" : margin >= 15 ? "存在显著安全边际" : margin >= 5 ? "存在安全边际" : margin >= -5 ? "估值大致合理" : "估值偏贵";
  return {
    currentPrice,
    currentPe,
    industryPeMedian: b.pe_median,
    intrinsicValue: intrinsic,
    conservativeValue: conservative,
    margin,
    verdict,
    confidenceAdjust: scorecard.composite >= 70 ? "high" : "medium",
  };
}

function detectChaseRisk(payload) {
  const q = payload.quote?.parsed || {};
  const t = payload.technicals?.parsed || {};
  const s = payload.sentiment?.parsed || {};
  const change5d = t.change5d;
  const change1d = q.percentChange;
  const volumeRatio = t.volumeRatio;
  let classification = "事件驱动型";
  let signal = "可买入";
  let risk = "low";
  if ((change5d != null && change5d >= 10) || (change1d != null && change1d >= 5) || (volumeRatio != null && volumeRatio >= 1.8)) {
    classification = "交易回踩型";
    signal = "需谨慎/回避";
    risk = "high";
  } else if ((change5d != null && change5d >= 5) || (change1d != null && change1d >= 3) || (volumeRatio != null && volumeRatio >= 1.5)) {
    classification = "交易回踩型-轻度";
    signal = "需谨慎，可关注回踩机会";
    risk = "medium";
  }
  const hasEvent = (s.itemCount || 0) > 0;
  return {
    classification,
    signal,
    risk,
    hasEvent,
    metrics: { change5d, change1d, volumeRatio, newsCount: s.itemCount ?? 0 },
  };
}

function generateRecommendation(scorecard, safetyMargin, chaseRisk) {
  let signal = "持有";
  if (scorecard.composite >= 75 && chaseRisk.risk === "low" && (safetyMargin.margin ?? -999) >= 5) signal = "买入";
  else if (chaseRisk.risk === "high") signal = "回避追高";
  else if (scorecard.composite < 50) signal = "观望";
  return {
    signal,
    horizon: "中期(3-6个月)",
    scenarios: {
      bullMarket: signal === "回避追高" ? "等待回踩后再参与" : "可分批加仓并跟踪量价确认",
      bearMarket: "控制仓位，采用分批建仓与止损纪律",
      sideways: "高抛低吸，等待催化事件进一步验证",
    },
  };
}

const REPORTIFY_CUSTOM_MAX_BLOCKS = 6;
const REPORTIFY_CUSTOM_MAX_CHARS_PER_BLOCK = 12000;

function extractReportifyCustomBlocks(inputText) {
  const text = String(inputText || "");
  const matches = text.match(/<reportify-custom[\s\S]*?<\/reportify-custom>/gi);
  if (!matches || matches.length === 0) return [];
  return matches.slice(0, REPORTIFY_CUSTOM_MAX_BLOCKS);
}

function normalizeNewlines(text) {
  return String(text || "").replace(/\r\n?/g, "\n");
}

function maxConsecutiveCharRun(text, ch) {
  const s = String(text || "");
  let max = 0;
  let cur = 0;
  for (let i = 0; i < s.length; i++) {
    if (s[i] === ch) {
      cur += 1;
      if (cur > max) max = cur;
    } else {
      cur = 0;
    }
  }
  return max;
}

function toFencedCodeBlock(content, info = "text") {
  const body = normalizeNewlines(content);
  const maxTicks = maxConsecutiveCharRun(body, "`");
  const fenceLen = Math.max(3, maxTicks + 1);
  const fence = "`".repeat(fenceLen);
  return `${fence}${info}\n${body}\n${fence}`;
}

function formatReportifyCustomBlocksSafe(inputText) {
  const blocks = extractReportifyCustomBlocks(inputText);
  if (blocks.length === 0) return [];
  const lines = [];
  lines.push("## reportify-custom（安全呈现）");
  lines.push("> 以下内容来自原始输入。为降低 XSS 风险，已按纯文本代码块输出（不会作为 Markdown/HTML 执行）。");
  for (const rawBlock of blocks) {
    const redacted = redactSecrets(rawBlock);
    const clipped =
      redacted.length > REPORTIFY_CUSTOM_MAX_CHARS_PER_BLOCK
        ? `${redacted.slice(0, REPORTIFY_CUSTOM_MAX_CHARS_PER_BLOCK)}\n...[truncated]...`
        : redacted;
    lines.push("");
    lines.push(toFencedCodeBlock(clipped, "reportify-custom"));
  }
  return lines;
}

function formatMarkdown(result) {
  const { symbol, market, mode, data, quality, evolution, analysis } = result;
  const q = data.quote?.parsed || {};
  const f = data.fundamentals?.parsed || {};
  const t = data.technicals?.parsed || {};
  const s = data.sentiment?.parsed || {};
  const x = data.x_sentiment?.parsed || {};
  const scorecard = analysis?.scorecard || {};
  const safetyMargin = analysis?.safetyMargin || {};
  const chaseRisk = analysis?.chaseRisk || {};
  const recommendation = analysis?.recommendation || {};
  const now = new Date().toISOString();
  const evidenceMode = Boolean(result?.runtime?.evidenceMode);
  const lines = [];

  lines.push(`# ${symbol} 深度投资分析报告`);
  lines.push(`> 数据截止时间: ${now}`);
  lines.push(`> 市场: ${market} | 模式: ${mode} | 质量置信度: ${quality.confidence}`);
  lines.push("");
  lines.push("## 核心结论");
  lines.push(`- 综合评级: ${scorecard.grade ?? "N/A"} (${scorecard.composite ?? "N/A"}/100)`);
  lines.push(`- 投资信号: ${recommendation.signal ?? "N/A"}`);
  lines.push(`- 时序分类: ${chaseRisk.classification ?? "N/A"}（${chaseRisk.signal ?? "N/A"}）`);
  lines.push(`- 安全边际: ${formatPct(safetyMargin.margin)}，结论: ${safetyMargin.verdict ?? "N/A"}`);
  lines.push("");

  lines.push("## 一、实时行情与交易状态");
  lines.push(`- 最新价: ${formatNum(q.price)} | 当日涨跌: ${formatPct(q.percentChange)} | 成交量: ${formatNum(q.volume, 0)}`);
  lines.push(`- 近5日涨跌: ${formatPct(t.change5d)} | 成交量比(今/前5均): ${formatNum(t.volumeRatio)}`);
  lines.push(`- 52周区间: ${formatNum(f.week52Low)} - ${formatNum(f.week52High)}`);
  lines.push("");

  lines.push("## 二、基本面核心数据");
  lines.push(`- 公司: ${f.name ?? "N/A"} | 报告期: ${f.reportPeriod ?? f.latestQuarter ?? "N/A"}`);
  lines.push(
    `- 行业/主营: ${f.industry ?? "N/A"} / ${f.mainBusiness ?? "N/A"}`,
  );
  if (Array.isArray(f.tags) && f.tags.length > 0) {
    lines.push(`- 标签: ${f.tags.join("、")}`);
  }
  lines.push(
    `- 营收/净利润: ${formatAmountByMarket(f.revenue, market)} / ${formatAmountByMarket(f.netProfit, market)}`,
  );
  lines.push(
    `- 总资产/总负债: ${formatAmountByMarket(f.totalAssets, market)} / ${formatAmountByMarket(f.totalLiabilities, market)}`,
  );
  lines.push(
    `- 经营现金流: ${formatAmountByMarket(f.operatingCashflow, market)} | 利润率: ${formatRatioPct(f.profitMargin)}`,
  );
  lines.push(`- 估值: PE ${formatNum(f.pe)} | PB ${formatNum(f.pb)} | 市值 ${formatAmountByMarket(f.marketCap, market)}`);
  lines.push("");

  lines.push("## 三、多维度评分");
  lines.push(`- 估值: ${scorecard.score?.valuation ?? "N/A"} (权重30%)`);
  lines.push(`- 财务质量: ${scorecard.score?.quality ?? "N/A"} (权重25%)`);
  lines.push(`- 成长性: ${scorecard.score?.growth ?? "N/A"} (权重20%)`);
  lines.push(`- 技术面: ${scorecard.score?.technicals ?? "N/A"} (权重15%)`);
  lines.push(`- 情绪面: ${scorecard.score?.sentiment ?? "N/A"} (权重10%)`);
  lines.push(`- 综合分: ${scorecard.composite ?? "N/A"} -> ${scorecard.grade ?? "N/A"}`);
  lines.push("");

  lines.push("## 四、事件时序与追高风险");
  lines.push(`- 分类: ${chaseRisk.classification ?? "N/A"}`);
  lines.push(`- 发布前5日涨幅: ${formatPct(chaseRisk.metrics?.change5d)}`);
  lines.push(`- 发布前1日/当日涨幅: ${formatPct(chaseRisk.metrics?.change1d)}`);
  lines.push(`- 成交量异常比: ${formatNum(chaseRisk.metrics?.volumeRatio)} (>=1.5通常视为异常)`);
  lines.push(`- 近期新闻条数: ${chaseRisk.metrics?.newsCount ?? "N/A"}`);
  lines.push(`- 结论: ${chaseRisk.signal ?? "N/A"}`);
  lines.push("");

  lines.push("## 五、情绪与资讯精华");
  lines.push(`- 新闻条数: ${s.itemCount ?? "N/A"} | 最新标题: ${s.latestHeadline ?? "N/A"}`);
  lines.push(`- 研报/新闻情绪值: ${s.latestTickerSentiment ?? "N/A"}`);
  lines.push(`- X热度: ${x.itemCount ?? "N/A"} | 热门内容: ${x.topPostText ?? "N/A"}`);
  if (result?.runtime?.includeSourceUrls && s.fullContentFileUrl) lines.push(`- 资讯源URL: ${s.fullContentFileUrl}`);
  lines.push("");

  lines.push("## 六、情景化策略");
  lines.push(`- 牛市: ${recommendation.scenarios?.bullMarket ?? "N/A"}`);
  lines.push(`- 熊市: ${recommendation.scenarios?.bearMarket ?? "N/A"}`);
  lines.push(`- 震荡市: ${recommendation.scenarios?.sideways ?? "N/A"}`);
  lines.push(`- 建议周期: ${recommendation.horizon ?? "N/A"}`);
  lines.push("");

  lines.push("## 七、风险提示");
  for (const warning of quality.warnings || []) lines.push(`- ${warning}`);
  for (const gap of data?.fundamentals?.dataGaps || []) lines.push(`- ${gap}`);
  lines.push("- 事件驱动型信号也不能保证一定上涨，需结合基本面和市场环境综合判断。");
  lines.push("- 交易回踩型不代表股票一定下跌，只是短期风险较高，可等待回调后再介入。");
  lines.push("- 本分析基于公开信息与历史时序关系，不构成投资建议。");
  lines.push("- 投资者应设置止损并自担风险。");
  lines.push("");

  lines.push("## 八、系统透明度");
  lines.push(`- 工具链路由: ${JSON.stringify(evolution?.route_source || {})}`);
  lines.push(`- 参数模板命中次数: ${evolution?.template_hits ?? 0}`);
  lines.push(`- 本次新学习工具数: ${evolution?.new_tools_learned?.length ?? 0}`);
  lines.push("");

  if (evidenceMode) {
    lines.push("## 九、数据字段明细（可用字段全展示）");
    lines.push("");
    appendFieldDetails(lines, "### Quote Parsed Fields", q, market, ["raw"]);
    appendFieldDetails(lines, "### Fundamentals Parsed Fields", f, market, ["raw"]);
    appendFieldDetails(lines, "### Technicals Parsed Fields", t, market, ["raw"]);
    appendFieldDetails(lines, "### Sentiment Parsed Fields", s, market, ["raw"]);
    appendFieldDetails(lines, "### X Sentiment Parsed Fields", x, market, ["raw"]);
    appendRawSnapshot(lines, "### Quote Raw Snapshot", q.raw, market, 30);
    appendRawSnapshot(lines, "### Fundamentals Raw Snapshot", f.raw, market, 35);
    appendRawSnapshot(lines, "### Technicals Raw Snapshot", t.raw, market, 25);
  }

  const customSection = formatReportifyCustomBlocksSafe(result?.runtime?.resolvedInput?.original);
  if (customSection.length > 0) {
    lines.push("");
    lines.push(...customSection);
  }
  return lines.join("\n");
}

function capabilitiesForMode(mode) {
  if (mode === "basic") return ["quote", "fundamentals"];
  if (mode === "fundamental") return ["quote", "fundamentals", "sentiment", "x_sentiment"];
  if (mode === "technical") return ["quote", "technicals", "sentiment", "x_sentiment"];
  return ["quote", "fundamentals", "technicals", "sentiment", "x_sentiment"];
}

function historyDateRange(days = 45) {
  const end = new Date();
  const start = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
  const fmt = (d) => d.toISOString().slice(0, 10);
  return { start: fmt(start), end: fmt(end) };
}

function latestCompletedReportPeriod() {
  const now = new Date();
  const month = now.getMonth() + 1;
  let year = now.getFullYear();
  let period = "0930";
  if (month <= 4) {
    year -= 1;
    period = "0930";
  } else if (month <= 8) {
    period = "0331";
  } else if (month <= 10) {
    period = "0630";
  } else {
    period = "0930";
  }
  return { year: String(year), period };
}

function mergeFundamentalFields(base, extra) {
  const result = { ...(base || {}) };
  if ((result.industry == null || result.industry === "") && extra?.industry) result.industry = extra.industry;
  if ((result.mainBusiness == null || result.mainBusiness === "") && extra?.mainBusiness) {
    result.mainBusiness = extra.mainBusiness;
  }
  if ((!Array.isArray(result.tags) || result.tags.length === 0) && Array.isArray(extra?.tags) && extra.tags.length > 0) {
    result.tags = extra.tags;
  }
  const fields = [
    "symbol",
    "name",
    "marketCap",
    "pe",
    "forwardPe",
    "pb",
    "ps",
    "profitMargin",
    "revenueGrowthYoy",
    "earningsGrowthYoy",
    "week52High",
    "week52Low",
    "latestQuarter",
    "statementType",
    "reportPeriod",
    "revenue",
    "netProfit",
    "totalAssets",
    "totalLiabilities",
    "operatingCashflow",
  ];
  for (const key of fields) {
    if ((result[key] == null || result[key] === "") && extra?.[key] != null && extra[key] !== "") {
      result[key] = extra[key];
    }
  }
  return result;
}

async function fetchThsStatementParsed(symbol, market, statementType, options) {
  const report = latestCompletedReportPeriod();
  const params = {
    statement_type: statementType,
    codes: toThsCode(symbol, market),
    year: report.year,
    period: report.period,
    type: "1",
  };
  const raw = await executeTool("ths_ifind.financial_statements.v1", null, params, options.maxSize, options.timeoutMs);
  if (!isToolCallSuccessful(raw)) return null;
  const parsed = parseCapability("fundamentals", raw);
  if (!parsed) return null;
  if (!parsed.reportPeriod) parsed.reportPeriod = `${report.year}-${report.period}`;
  return { parsed, params };
}

async function fetchThsCompanyBasicsParsed(symbol, market, options) {
  const params = { codes: toThsCode(symbol, market) };
  const raw = await executeTool("ths_ifind.company_basics.v1", null, params, options.maxSize, options.timeoutMs);
  if (!isToolCallSuccessful(raw)) return null;
  const parsed = parseCapability("fundamentals", raw);
  if (!parsed) return null;
  return { parsed, params };
}

async function enrichCnHkFundamentals(symbol, market, output, options) {
  const base = output?.fundamentals?.parsed || {};
  let merged = { ...base };
  const supplementary = [];
  const dataGaps = [];

  try {
    const basics = await fetchThsCompanyBasicsParsed(symbol, market, options);
    if (basics?.parsed) {
      merged = mergeFundamentalFields(merged, basics.parsed);
      if (!merged.industry && basics.parsed.industry) merged.industry = basics.parsed.industry;
      if (!merged.mainBusiness && basics.parsed.mainBusiness) merged.mainBusiness = basics.parsed.mainBusiness;
      if ((!Array.isArray(merged.tags) || merged.tags.length === 0) && Array.isArray(basics.parsed.tags)) {
        merged.tags = basics.parsed.tags;
      }
      supplementary.push({ statement: "company_basics", params: basics.params, ok: true });
    } else {
      supplementary.push({ statement: "company_basics", ok: false, error: "empty result" });
      dataGaps.push("公司画像字段（行业/主营/标签）获取不完整，已尝试 company_basics 补齐。");
    }
  } catch (error) {
    supplementary.push({ statement: "company_basics", ok: false, error: redactSecrets(error.message) });
    dataGaps.push("公司画像字段（行业/主营/标签）获取失败，数据源或参数可能受限。");
  }

  if (merged.revenue == null || merged.netProfit == null) {
    try {
      const income = await fetchThsStatementParsed(symbol, market, "income", options);
      if (income?.parsed) {
        const beforeRevenue = merged.revenue;
        const beforeNetProfit = merged.netProfit;
        merged = mergeFundamentalFields(merged, income.parsed);
        const filled = beforeRevenue == null ? merged.revenue != null : beforeNetProfit == null ? merged.netProfit != null : false;
        supplementary.push({ statement: "income", params: income.params, ok: true, filled });
      } else {
        supplementary.push({ statement: "income", ok: false, error: "empty result" });
      }
    } catch (error) {
      supplementary.push({ statement: "income", ok: false, error: redactSecrets(error.message) });
    }
  }

  if (merged.totalAssets == null || merged.totalLiabilities == null) {
    try {
      const balance = await fetchThsStatementParsed(symbol, market, "balance", options);
      if (balance?.parsed) {
        const beforeAssets = merged.totalAssets;
        const beforeLiab = merged.totalLiabilities;
        merged = mergeFundamentalFields(merged, balance.parsed);
        supplementary.push({
          statement: "balance",
          params: balance.params,
          ok: true,
          filled: (beforeAssets == null && merged.totalAssets != null) || (beforeLiab == null && merged.totalLiabilities != null),
        });
      }
    } catch (error) {
      supplementary.push({ statement: "balance", ok: false, error: redactSecrets(error.message) });
    }
  }
  if (merged.operatingCashflow == null) {
    try {
      const cash = await fetchThsStatementParsed(symbol, market, "cash_flow", options);
      if (cash?.parsed) {
        const beforeCash = merged.operatingCashflow;
        merged = mergeFundamentalFields(merged, cash.parsed);
        supplementary.push({
          statement: "cash_flow",
          params: cash.params,
          ok: true,
          filled: beforeCash == null && merged.operatingCashflow != null,
        });
      }
    } catch (error) {
      supplementary.push({ statement: "cash_flow", ok: false, error: redactSecrets(error.message) });
    }
  }

  const quote = output?.quote?.parsed || {};
  merged = mergeFundamentalFields(merged, {
    marketCap: quote.marketCap,
    pe: quote.pe,
    pb: quote.pb,
  });

  if (!output.fundamentals) output.fundamentals = { success: false, parsed: {} };
  output.fundamentals.parsed = merged;
  const missingIncome = merged.revenue == null || merged.netProfit == null;
  const missingCashflow = merged.operatingCashflow == null;
  if (market === "HK" && missingIncome) {
    dataGaps.push("港股利润表字段（营收/净利润）当前在部分工具返回空值，已自动回退仍未补全。");
  }
  if (market === "HK" && missingCashflow) {
    dataGaps.push("港股现金流字段当前在部分工具返回空值，已自动回退仍未补全。");
  }
  if ((merged.industry == null || merged.mainBusiness == null) && ["CN", "HK"].includes(market)) {
    dataGaps.push("公司画像字段（行业/主营）存在空缺，已调用 company_basics 但数据源未完全返回。");
  }
  if (dataGaps.length > 0) {
    output.fundamentals.dataGaps = [...new Set(dataGaps)];
  }
  if (supplementary.length > 0) {
    output.fundamentals.supplementary = supplementary;
  }
}

function capabilityQueries(capability, market) {
  const queries = [CAPABILITY_QUERIES[capability]];
  if (capability === "x_sentiment") {
    queries.push("X Finance domain hot topics API");
  }
  if (capability === "sentiment" && ["CN", "HK"].includes(market)) {
    queries.unshift("caidazi A股 港股 研报 新闻 公众号 搜索");
    queries.push("xiaosu 股票 新闻 事件 联网搜索 API");
  }
  if (["CN", "HK"].includes(market) && CN_HK_QUERY_HINTS[capability]) {
    queries.push(CN_HK_QUERY_HINTS[capability]);
  }
  return queries;
}

function rankAndFilterCandidates(candidates, capability, market) {
  let ranked = rankTools([...candidates])
    .filter((tool) => isToolCompatibleForCapability(tool?.tool_id, capability, market))
    .sort(
      (a, b) =>
        (b._score || 0) +
        marketCapabilityBoost(b, capability, market) -
        ((a._score || 0) + marketCapabilityBoost(a, capability, market)),
    );
  ranked = prioritizeCandidatesByMarket(ranked, capability, market);
  if (capability === "fundamentals" && ["CN", "HK"].includes(market)) {
    ranked = ranked.sort((a, b) => {
      const p = thsFundamentalPriority(b?.tool_id) - thsFundamentalPriority(a?.tool_id);
      if (p !== 0) return p;
      return (b._score || 0) - (a._score || 0);
    });
  }
  if (capability === "x_sentiment") {
    ranked = ranked.filter((tool) => {
      const id = tool?.tool_id || "";
      return X_SENTIMENT_TOOL_PREFIXES.some((prefix) => id.startsWith(prefix));
    });
    ranked = ranked.sort((a, b) => {
      const aId = a.tool_id || "";
      const bId = b.tool_id || "";
      const aPrimary = aId.startsWith("x_developer.2.tweets.search.recent") ? 1 : 0;
      const bPrimary = bId.startsWith("x_developer.2.tweets.search.recent") ? 1 : 0;
      if (aPrimary !== bPrimary) return bPrimary - aPrimary;
      return (b._score || 0) - (a._score || 0);
    });
  }
  if (capability === "sentiment" && ["CN", "HK"].includes(market)) {
    const caidaziOnly = ranked.filter((tool) => isCaidaziTool(tool?.tool_id));
    if (caidaziOnly.length > 0) {
      caidaziOnly.sort((a, b) => {
        const p = caidaziPriority(b?.tool_id) - caidaziPriority(a?.tool_id);
        if (p !== 0) return p;
        return (b._score || 0) - (a._score || 0);
      });
      ranked = [...caidaziOnly, ...ranked.filter((tool) => !isCaidaziTool(tool?.tool_id))];
    }
  }
  return ranked;
}

async function searchByToolPrefix(prefix, capability, options) {
  const query = `${prefix} ${capability} API`;
  const res = await searchTools(query, Math.max(options.limit, 8), options.timeoutMs);
  const matched = (res.results || []).filter((tool) => String(tool?.tool_id || "").startsWith(prefix));
  return { matched, searchId: res.search_id };
}

async function searchByToolChain(capability, options) {
  const chains = await loadToolChains();
  const market = options.market;
  const mapping = chains?.[capability] || {};
  const prefixes =
    mapping?.[market] ||
    mapping?.GLOBAL ||
    (capability === "x_sentiment" ? mapping?.ALL : null) ||
    [];
  if (!Array.isArray(prefixes) || prefixes.length === 0) {
    return { ranked: [], searchId: null, routeSource: "none", preferredPrefixes: [] };
  }
  const all = [];
  let firstSearchId = null;
  for (const prefix of prefixes) {
    try {
      const result = await searchByToolPrefix(prefix, capability, options);
      if (!firstSearchId) firstSearchId = result.searchId;
      all.push(...result.matched);
    } catch {
      // ignore and continue chain fallback
    }
  }
  const unique = new Map();
  for (const tool of all) {
    const key = tool.tool_id;
    if (!unique.has(key) || scoreTool(tool) > scoreTool(unique.get(key))) unique.set(key, tool);
  }
  const ranked = rankAndFilterCandidates([...unique.values()], capability, market);
  return { ranked, searchId: firstSearchId, routeSource: "chain", preferredPrefixes: prefixes };
}

async function searchAndRankByCapability(capability, options) {
  const all = [];
  let chosenSearchId = null;
  for (const query of capabilityQueries(capability, options.market)) {
    const res = await searchTools(query, options.limit, options.timeoutMs);
    chosenSearchId = chosenSearchId || res.search_id;
    for (const tool of res.results || []) {
      all.push(tool);
    }
  }
  const bestById = new Map();
  for (const tool of all) {
    const key = tool.tool_id;
    if (!bestById.has(key) || scoreTool(tool) > scoreTool(bestById.get(key))) {
      bestById.set(key, tool);
    }
  }
  const ranked = rankAndFilterCandidates([...bestById.values()], capability, options.market);
  return { ranked, searchId: chosenSearchId };
}

async function runSingleAnalysis(symbol, options) {
  const resolvedInput = await resolveRequestedSymbol(symbol, options.market);
  const effectiveMarket = resolvedInput.market;
  const candidates = normalizeSymbols(resolvedInput.symbol, effectiveMarket);
  let selectedSymbol = candidates[0];
  const output = { quote: null, fundamentals: null, technicals: null, sentiment: null, x_sentiment: null };
  const evolutionSummary = {
    template_hits: 0,
    new_tools_learned: [],
    route_source: {},
  };

  const activeCapabilities = capabilitiesForMode(options.mode);

  for (const candidate of candidates) {
    selectedSymbol = candidate;
    let foundAny = false;
    for (const capability of activeCapabilities) {
      let ranked = [];
      let searchId = null;
      let routeSource = "chain";
      let preferredPrefixes = [];
      const runtimeOptions = { ...options, market: effectiveMarket };
      const chained = await searchByToolChain(capability, runtimeOptions);
      ranked = chained.ranked;
      searchId = chained.searchId;
      routeSource = chained.routeSource;
      preferredPrefixes = chained.preferredPrefixes || [];
      if (ranked.length === 0) {
        const search = await searchAndRankByCapability(capability, runtimeOptions);
        ranked = search.ranked;
        searchId = search.searchId;
        routeSource = "fallback";
      }
      evolutionSummary.route_source[capability] = routeSource;
      if (ranked.length === 0) {
        output[capability] = { success: false, reason: "no tools found", attempts: [] };
        continue;
      }
      foundAny = true;
      const executed = await executeWithFallback(
        ranked,
        searchId,
        (tool) =>
          buildParamsForCapability(capability, candidate, options.mode, tool, effectiveMarket, {
            originalInput: resolvedInput.original,
            evolutionState: options.evolutionState,
            templateHitCollector: evolutionSummary,
          }),
        options,
        {
          capability,
          market: effectiveMarket,
          symbol: candidate,
          successValidator: (raw) => isParsedDataUsable(capability, parseCapability(capability, raw)),
        },
      );
      output[capability] = {
        ...executed,
        rankedTop: ranked.slice(0, 3).map((t) => t.tool_id),
        preferredPrefixes,
        routeSource,
      };
      if (executed.success) {
        output[capability].parsed = sanitizeParsedCapability(
          parseCapability(capability, executed.successfulResult),
          options,
        );
      }
      delete output[capability].successfulResult;
    }
    if (foundAny) break;
  }

  if (["CN", "HK"].includes(effectiveMarket)) {
    await enrichCnHkFundamentals(selectedSymbol, effectiveMarket, output, options);
  }

  const quality = summarizeQualityV2(output);
  const benchmarks = await loadSectorBenchmarks();
  const scorecard = buildScorecardV2(output, benchmarks, effectiveMarket);
  const safetyMargin = calcSafetyMargin(output, scorecard, benchmarks, effectiveMarket);
  const chaseRisk = detectChaseRiskV2(output);
  const recommendation = generateRecommendation(scorecard, safetyMargin, chaseRisk);
  const financialQuality = buildFinancialQuality(output, benchmarks, effectiveMarket);
  const valuationFrame = buildValuationFrame(output, scorecard, benchmarks, effectiveMarket);
  const events = options.eventRadar
    ? extractEvents(output.sentiment?.parsed, output.x_sentiment?.parsed, {
        maxEvents: Math.max(5, Math.min(12, Number(options.eventWindowDays || 7))),
      })
    : [];
  const eventRadar = {
    view: options.eventView || "timeline",
    events,
    themes: (options.eventView || "timeline") === "theme" ? clusterEventsByTheme(events) : [],
    ideas: buildEventRadarIdeas(events, selectedSymbol, effectiveMarket),
    universe: options.eventUniverse || "global",
  };
  const thesis = buildThesis(output, effectiveMarket, scorecard, valuationFrame, financialQuality, eventRadar);
  const playbooks = buildPlaybooks(
    { scorecard, valuationFrame, chaseRisk, thesis, eventRadar },
    {
      horizon: options.horizon,
      risk: options.risk,
      style: options.style,
      actionable: options.actionable,
    },
  );
  evolutionSummary.new_tools_learned = [...new Set(options.newlyLearnedTools || [])];
  const dataSources = buildDataSourcesSummary(output);
  return {
    symbol: selectedSymbol,
    market: effectiveMarket,
    mode: options.mode,
    data: output,
    dataSources,
    quality,
    analysis: {
      scorecard,
      safetyMargin,
      chaseRisk,
      recommendation,
      financialQuality,
      valuationFrame,
      thesis,
      eventRadar,
      playbooks,
    },
    evolution: evolutionSummary,
    runtime: {
      includeSourceUrls: Boolean(options.includeSourceUrls),
      evidenceMode: Boolean(options.evidenceMode),
      evolutionEnabled: Boolean(options.evolutionEnabled),
      summaryOnly: Boolean(options.summaryOnly),
      resolvedInput: {
        original: resolvedInput.original,
        symbol: resolvedInput.symbol,
        market: resolvedInput.market,
        resolvedBy: resolvedInput.resolvedBy,
      },
    },
    meta: {
      note: "Analysis delegated to OpenClaw LLM via SKILL.md Single Stock Analysis Guide",
      guide: "Single Stock Analysis Guide",
    },
  };
}

function parseCapability(capability, raw) {
  return parseCapabilityV2(capability, raw);
}

function sanitizeParsedCapability(parsed, options) {
  if (!parsed || typeof parsed !== "object") return parsed;
  const cleaned = { ...parsed };
  if (!options?.includeSourceUrls) {
    delete cleaned.fullContentFileUrl;
  }
  return cleaned;
}

function providerFromToolId(toolId) {
  const id = String(toolId || "").trim();
  if (!id) return "unknown";
  return id.split(".")[0] || "unknown";
}

function buildDataSourcesSummary(output) {
  const rows = Object.entries(output || {})
    .filter(([, value]) => value?.success && value?.selectedTool?.tool_id)
    .map(([capability, value]) => ({
      capability,
      provider: providerFromToolId(value.selectedTool.tool_id),
      toolId: value.selectedTool.tool_id,
      route: value.routeSource || "unknown",
    }));
  const dedup = new Map();
  for (const row of rows) {
    const key = `${row.capability}#${row.toolId}`;
    if (!dedup.has(key)) dedup.set(key, row);
  }
  return [...dedup.values()];
}

function buildDefaultParamsForCapability(capability, symbol, mode, tool, market, context = {}) {
  const toolId = tool?.tool_id || "";
  const thsCode = toThsCode(symbol, market);
  const base = String(symbol || "").toUpperCase().replace(/\.(US|HK|SH|SZ|SS)$/, "");
  const originalInput = String(context.originalInput || "").trim();
  const keyword = hasCjk(originalInput) ? originalInput : hasCjk(symbol) ? String(symbol) : base;
  const caidaziTicker = market === "HK" ? base.replace(/^0+/, "").padStart(4, "0") : base;
  const recentStart = historyDateRange(120).start;
  const report = latestCompletedReportPeriod();

  if (toolId.startsWith("ths_ifind.real_time_quotation")) {
    return { codes: thsCode };
  }
  if (toolId.startsWith("ths_ifind.company_basics")) {
    return { codes: thsCode };
  }
  if (toolId.startsWith("ths_ifind.financial_statements")) {
    return { statement_type: "income", codes: thsCode, year: report.year, period: report.period, type: "1" };
  }
  if (toolId.startsWith("ths_ifind.income_statement")) {
    return { codes: thsCode, year: report.year, period: report.period, type: "1" };
  }
  if (toolId.startsWith("ths_ifind.balance_sheet")) {
    return { codes: thsCode, year: report.year, period: report.period, type: "1" };
  }
  if (toolId.startsWith("ths_ifind.cash_flow_statement")) {
    return { codes: thsCode, year: report.year, period: report.period, type: "1" };
  }
  if (toolId.startsWith("ths_ifind.history_quotation")) {
    const range = historyDateRange(45);
    return { codes: thsCode, startdate: range.start, enddate: range.end, interval: "D" };
  }
  if (toolId.startsWith("finnhub.news")) {
    return { category: "general" };
  }
  if (toolId.startsWith("caidazi.search.hybrid_v2.query")) {
    return {
      input: keyword,
      ticker: caidaziTicker,
      sourceType: "report,news,wechat",
      size: 5,
      highlight: "true",
      sortOrder: "desc",
      publishTimeStart: recentStart,
      timeSensitive: true,
    };
  }
  if (toolId.startsWith("caidazi.news.query")) {
    return {
      input: keyword,
      ticker: caidaziTicker,
      size: 5,
      highlight: "true",
      sortOrder: "desc",
      publishTimeStart: recentStart,
    };
  }
  if (toolId.startsWith("caidazi.report.query")) {
    return {
      input: keyword,
      tsCode: caidaziTicker,
      size: 5,
      highlight: "true",
      sortOrder: "desc",
      publishDateStart: recentStart,
    };
  }
  if (toolId.startsWith("caidazi.search.hybrid.list")) {
    return { input: keyword };
  }
  if (toolId.startsWith("x_developer.2.tweets.search.recent")) {
    return {
      query: `(${base} OR $${base}) lang:en -is:retweet`,
      max_results: 20,
      sort_order: "relevancy",
    };
  }
  if (toolId.startsWith("qveris_social.x_domain_new_posts")) {
    return { domains: ["Finance"], limit: 20 };
  }
  if (toolId.startsWith("qveris_social.x_domain_hot_topics")) {
    return { domains: ["Finance"], limit: 20, min_engagement: 30 };
  }
  if (toolId.startsWith("qveris_social.x_domain_hot_events")) {
    return { domains: ["Finance"] };
  }

  if (capability === "quote") {
    return { symbol };
  }
  if (capability === "fundamentals") {
    return { function: "OVERVIEW", symbol };
  }
  if (capability === "technicals") {
    const interval = mode === "technical" ? "daily" : "daily";
    return { function: "RSI", symbol, interval, series_type: "close", time_period: 14 };
  }
  if (capability === "sentiment") {
    return { function: "NEWS_SENTIMENT", tickers: symbol, sort: "LATEST", limit: 10 };
  }
  if (capability === "x_sentiment") {
    return {
      query: `(${base} OR $${base}) lang:en -is:retweet`,
      max_results: 20,
      sort_order: "relevancy",
    };
  }
  return { symbol };
}

function buildParamsForCapability(capability, symbol, mode, tool, market, context = {}) {
  const toolId = tool?.tool_id || "";
  const thsCode = toThsCode(symbol, market);
  const base = String(symbol || "").toUpperCase().replace(/\.(US|HK|SH|SZ|SS)$/, "");
  const originalInput = String(context.originalInput || "").trim();
  const keyword = hasCjk(originalInput) ? originalInput : hasCjk(symbol) ? String(symbol) : base;
  const caidaziTicker = market === "HK" ? base.replace(/^0+/, "").padStart(4, "0") : base;
  const recentStart = historyDateRange(120).start;
  const history = historyDateRange(45);
  const report = latestCompletedReportPeriod();
  const paramContext = {
    symbol,
    mode,
    market,
    thsCode,
    base,
    keyword,
    caidaziTicker,
    recentStart,
    history,
    report,
  };
  const template = getEvolutionTemplate(context.evolutionState, toolId, market);
  if (template) {
    const interpolated = interpolateTemplate(template, paramContext);
    if (context.templateHitCollector) {
      context.templateHitCollector.template_hits = (context.templateHitCollector.template_hits || 0) + 1;
    }
    return { params: interpolated, paramContext };
  }
  const params = buildDefaultParamsForCapability(capability, symbol, mode, tool, market, context);
  return { params, paramContext };
}

function parseArgs(argv) {
  const args = argv.slice(2);
  if (args.length === 0 || args.includes("--help") || args.includes("-h")) return { help: true };
  const command = args[0];
  const parsed = {
    command,
    market: "GLOBAL",
    mode: "comprehensive",
    format: "markdown",
    limit: 10,
    maxSize: 30000,
    timeoutMs: 25000,
    maxAttemptsPerPhase: 3,
    includeSourceUrls: false,
    evidenceMode: false,
    evolutionEnabled: true,
    actionable: false,
    skipQuestionnaire: false,
    summaryOnly: false,
    eventRadar: true,
    eventWindowDays: 7,
    eventUniverse: "global",
    eventView: "timeline",
    horizon: null,
    risk: null,
    style: null,
  };

  for (let i = 1; i < args.length; i++) {
    const key = args[i];
    if (key === "--symbol" && i + 1 < args.length) parsed.symbol = args[++i];
    else if (key === "--symbols" && i + 1 < args.length) parsed.symbols = args[++i];
    else if (key === "--market" && i + 1 < args.length) parsed.market = args[++i].toUpperCase();
    else if (key === "--mode" && i + 1 < args.length) parsed.mode = args[++i];
    else if (key === "--format" && i + 1 < args.length) parsed.format = args[++i];
    else if (key === "--type" && i + 1 < args.length) parsed.type = args[++i];
    else if (key === "--action" && i + 1 < args.length) parsed.watchAction = args[++i];
    else if (key === "--bucket" && i + 1 < args.length) parsed.bucket = args[++i];
    else if (key === "--max-items" && i + 1 < args.length) parsed.maxItems = Number(args[++i]) || 8;
    else if (key === "--limit" && i + 1 < args.length) parsed.limit = Number(args[++i]) || 10;
    else if (key === "--max-size" && i + 1 < args.length) parsed.maxSize = Number(args[++i]) || 30000;
    else if (key === "--timeout" && i + 1 < args.length) parsed.timeoutMs = (Number(args[++i]) || 25) * 1000;
    else if (key === "--include-source-urls") parsed.includeSourceUrls = true;
    else if (key === "--evidence") parsed.evidenceMode = true;
    else if (key === "--no-evolution") parsed.evolutionEnabled = false;
    else if (key === "--actionable") parsed.actionable = true;
    else if (key === "--skip-questionnaire") parsed.skipQuestionnaire = true;
    else if (key === "--summary-only") parsed.summaryOnly = true;
    else if (key === "--event-radar") parsed.eventRadar = true;
    else if (key === "--no-event-radar") parsed.eventRadar = false;
    else if (key === "--event-window-days" && i + 1 < args.length) parsed.eventWindowDays = Number(args[++i]) || 7;
    else if (key === "--event-universe" && i + 1 < args.length) parsed.eventUniverse = String(args[++i]).toLowerCase();
    else if (key === "--event-view" && i + 1 < args.length) parsed.eventView = String(args[++i]).toLowerCase();
    else if (key === "--horizon" && i + 1 < args.length) parsed.horizon = String(args[++i]).toLowerCase();
    else if (key === "--risk" && i + 1 < args.length) parsed.risk = String(args[++i]).toLowerCase();
    else if (key === "--style" && i + 1 < args.length) parsed.style = String(args[++i]).toLowerCase();
  }
  return parsed;
}

function formatRoutedMarkdown(result) {
  if (!result || typeof result !== "object") return String(result ?? "");
  if (result.mode === "watch") {
    const lines = ["# Watchlist 管理", `- 操作: ${result.action}`];
    const holdings = result.watchlist?.holdings || [];
    const watchlist = result.watchlist?.watchlist || [];
    lines.push("", "## 持仓");
    if (holdings.length === 0) lines.push("- (空)");
    for (const item of holdings) lines.push(`- ${item.symbol} (${item.market})`);
    lines.push("", "## 关注");
    if (watchlist.length === 0) lines.push("- (空)");
    for (const item of watchlist) lines.push(`- ${item.symbol} (${item.market})`);
    return lines.join("\n");
  }
  if (result.mode === "brief") {
    const lines = [`# ${result.type === "evening" ? "晚报" : "早报"}`, `- 生成时间: ${result.generatedAt}`];
    lines.push(`- 覆盖: 持仓 ${result.coverage?.holdings || 0} | 关注 ${result.coverage?.watchlist || 0}`);
    lines.push(`- 市场情绪: ${result.marketOverview?.sentiment || "unknown"}`);
    lines.push("", "## 市场概览");
    for (const idx of result.marketOverview?.indices || []) {
      if (idx.error) lines.push(`- ${idx.name}(${idx.symbol}): 数据失败 - ${idx.error}`);
      else lines.push(`- ${idx.name}(${idx.symbol}): ${idx.price ?? "N/A"} / ${idx.percentChange ?? "N/A"}%`);
    }
    lines.push("", "## 热点摘要");
    for (const item of result.highlights || []) {
      const tickerStr = item.tickers?.length ? ` | ${item.tickers.join(", ")}` : "";
      const categoryStr = item.category ? ` [${item.category}]` : "";
      lines.push(`- ${item.title}${categoryStr}${tickerStr}`);
      lines.push(`  - 来源: ${item.source}${item.publishTime ? ` | ${item.publishTime}` : ""}`);
    }
    lines.push("", "## 标的摘要");
    for (const row of result.reports || []) {
      if (row.error) lines.push(`- ${row.symbol} (${row.market}): 失败 - ${row.error}`);
      else lines.push(`- ${row.symbol} (${row.market}): ${row.price ?? "N/A"} / ${row.percentChange ?? "N/A"}% / ${row.grade} / ${row.signal} / 风险 ${row.risk}`);
    }
    lines.push("", "---");
    lines.push("*分析建议：请 OpenClaw 根据 SKILL.md 中的 Daily Brief Analysis Guide 输出专业且精炼的早晚报（包含投资逻辑与操作建议）*");
    return lines.join("\n");
  }
  if (result.mode === "radar") {
    const lines = ["# 行业热点雷达", `- 生成时间: ${result.generatedAt}`, `- 热点数: ${result.topicCount || 0}`];
    lines.push(`- 数据源数: ${result.meta?.sourceCount || 0}`);
    lines.push("", "## 热点详情");
    for (const t of result.topics || []) {
      const tickerStr = t.tickers?.length ? ` | 相关: ${t.tickers.join(", ")}` : "";
      const sentimentStr = t.sentiment != null ? ` | 情绪: ${t.sentiment.toFixed(2)}` : "";
      const categoryStr = t.category ? ` | 分类: ${t.category}` : "";
      lines.push(`- **${t.title || "未命名主题"}**`);
      lines.push(`  - 来源: ${t.source || "unknown"}${categoryStr}${tickerStr}${sentimentStr}`);
      if (t.publishTime) lines.push(`  - 时间: ${t.publishTime}`);
    }
    lines.push("", "## 数据源状态");
    for (const s of result.meta?.sourceStats || []) {
      lines.push(`- ${s.source}: ${s.ok ? "OK" : "FAIL"} / count=${s.count}`);
    }
    lines.push("", "---");
    lines.push("*分析建议：请 OpenClaw 根据 SKILL.md 中的 Hot Topic Analysis Guide 进行主题聚合与投资分析*");
    return lines.join("\n");
  }
  return JSON.stringify(result, null, 2);
}

function printHelp() {
  console.log(`Stock Copilot Pro

Usage:
  node scripts/stock_copilot_pro.mjs analyze --symbol AAPL [options]
  node scripts/stock_copilot_pro.mjs compare --symbols AAPL,MSFT [options]

Options:
  --market US|HK|CN|GLOBAL   default GLOBAL
  --mode basic|fundamental|technical|comprehensive   default comprehensive
  --format markdown|json|chat default markdown
  --action list|add|remove   for watch command
  --bucket holdings|watchlist for watch command
  --type morning|evening     for brief command
  --max-items N              max symbols in brief
  --limit N                  search results per capability (default 10)
  --max-size N               max response size bytes (default 30000)
  --timeout N                timeout seconds (default 25)
  --include-source-urls      include provider full-content URLs in report
  --evidence                 include full parsed/raw evidence sections in report
  --horizon short|mid|long   investment horizon preference
  --risk low|mid|high        risk preference
  --style value|balanced|growth|trading
                             preferred investment style
  --actionable               include execution-oriented strategy rules
  --skip-questionnaire       skip default preference questionnaire
  --summary-only             render summary-first compact report
  --event-window-days N      event window in days (default 7)
  --event-universe global|same_market
                             event radar universe scope
  --event-view timeline|theme
                             event output style
  --no-evolution             disable reading/writing .evolution state
  --help                     show this message
`);
}

async function main() {
  const args = parseArgs(process.argv);
  if (args.help) {
    printHelp();
    return;
  }
  args.evolutionState = args.evolutionEnabled ? await loadEvolutionState() : null;
  args.newlyLearnedTools = [];

  if (hasCommand(args.command)) {
    const result = await dispatchCommand(args.command, args, {
      analyzeSymbol: async (symbol, runArgs) => runSingleAnalysis(symbol, runArgs),
    });
    if (args.format === "json") {
      console.log(JSON.stringify(result, null, 2));
      return;
    }
    if (args.format === "chat" && result?.mode === "brief" && Array.isArray(result.reports)) {
      const chatReports = result.reports.filter((x) => !x.error).map((x) => ({
        symbol: x.symbol,
        market: x.market,
        analysis: { scorecard: { grade: x.grade }, recommendation: { signal: x.signal }, chaseRisk: { risk: x.risk } },
      }));
      console.log(
        chatReports
          .map((r) => `${buildDecisionCard(r).asText}`)
          .join("\n"),
      );
      return;
    }
    console.log(formatRoutedMarkdown(result));
    return;
  }

  if (args.command === "analyze") {
    if (!args.symbol) throw new Error("analyze requires --symbol");
    const preferences = parsePreferences(args);
    if (!args.skipQuestionnaire && !preferences.hasAny) {
      const questionnaireResult = {
        questionnaire: buildQuestionnaire(),
        runtime: {
          summaryOnly: Boolean(args.summaryOnly),
          resolvedInput: { original: args.symbol },
        },
      };
      if (args.format === "json") console.log(JSON.stringify(questionnaireResult, null, 2));
      else console.log(formatMarkdownV2(questionnaireResult));
      return;
    }
    const result = await runSingleAnalysis(args.symbol, args);
    result.evolution.new_tools_learned = [...new Set(args.newlyLearnedTools)];
    if (args.evolutionEnabled && args.evolutionState) await saveEvolutionState(args.evolutionState);
    if (args.format === "json") console.log(JSON.stringify(result, null, 2));
    else if (args.format === "chat") console.log(JSON.stringify(formatForChat(result), null, 2));
    else console.log(formatMarkdownV2(result));
    return;
  }

  if (args.command === "compare") {
    if (!args.symbols) throw new Error("compare requires --symbols");
    const symbols = args.symbols.split(",").map((s) => s.trim()).filter(Boolean);
    const reports = [];
    for (const s of symbols) {
      reports.push(await runSingleAnalysis(s, args));
    }
    if (args.evolutionEnabled && args.evolutionState) await saveEvolutionState(args.evolutionState);
    if (args.format === "json") {
      console.log(
        JSON.stringify(
          {
            mode: "compare",
            market: args.market,
            reports,
            evolution: { new_tools_learned: [...new Set(args.newlyLearnedTools)] },
          },
          null,
          2,
        ),
      );
    } else if (args.format === "chat") {
      console.log(
        JSON.stringify(
          {
            mode: "compare-chat",
            reports: reports.map((r) => ({
              symbol: r.symbol,
              market: r.market,
              decisionCard: buildDecisionCard(r),
              chat: formatForChat(r),
            })),
          },
          null,
          2,
        ),
      );
    } else {
      for (const report of reports) {
        report.evolution.new_tools_learned = [...new Set(args.newlyLearnedTools)];
        console.log(formatMarkdownV2(report));
        console.log("\n---\n");
      }
    }
    return;
  }

  throw new Error(`Unknown command: ${args.command}`);
}

function isDirectExecution() {
  const argvPath = process.argv?.[1] ? path.resolve(process.argv[1]) : "";
  return argvPath === __filename;
}

if (isDirectExecution()) {
  main().catch((err) => {
    if (err.name === "AbortError") {
      console.error("Error: request timeout");
    } else {
      console.error(`Error: ${redactSecrets(err.message)}`);
    }
    process.exit(1);
  });
}

export {
  extractReportifyCustomBlocks,
  formatReportifyCustomBlocksSafe,
  toFencedCodeBlock,
  normalizeNewlines,
  maxConsecutiveCharRun,
  isDirectExecution,
  formatMarkdown,
};



path: scripts/stock_copilot_pro.test.mjs
content: import test from "node:test";
import assert from "node:assert/strict";

import { formatMarkdown } from "./stock_copilot_pro.mjs";

function minimalResultWithOriginal(original) {
  return {
    symbol: "TEST",
    market: "US",
    mode: "basic",
    data: {
      quote: { parsed: {} },
      fundamentals: { parsed: {} },
      technicals: { parsed: {} },
      sentiment: { parsed: {} },
      x_sentiment: { parsed: {} },
    },
    quality: { confidence: 0.5, warnings: [] },
    evolution: { route_source: {}, template_hits: 0, new_tools_learned: [] },
    analysis: { scorecard: {}, safetyMargin: {}, chaseRisk: {}, recommendation: {} },
    runtime: { includeSourceUrls: false, resolvedInput: { original } },
  };
}

test("reportify-custom blocks are rendered as fenced code blocks (not raw HTML)", () => {
  const original =
    "hello <reportify-custom><img src=x onerror=alert(1)><script>alert(1)</script></reportify-custom> bye";
  const out = formatMarkdown(minimalResultWithOriginal(original));

  // Previously it was: "## reportify-custom\n<reportify-custom...>"
  assert.ok(!out.includes("## reportify-custom\n<reportify-custom"), "custom block should not be emitted raw");
  assert.ok(out.includes("## reportify-custom（安全呈现）"), "should include safe custom section header");
  assert.ok(out.includes("```reportify-custom"), "should include fenced code block with info string");
});

test("fence length expands when content contains triple backticks", () => {
  const original =
    "<reportify-custom>some content with ``` inside</reportify-custom>";
  const out = formatMarkdown(minimalResultWithOriginal(original));

  // We expect at least 4 backticks fence in this case to avoid premature closing.
  assert.ok(out.includes("````reportify-custom"), "should use a longer fence when content contains ```");
});



path: tests/analyze.e2e.test.mjs
content: import test from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SKILL_ROOT = path.resolve(__dirname, "..");
const SCRIPT_FILE = path.join(SKILL_ROOT, "scripts", "stock_copilot_pro.mjs");

const BASE_ARGS = [
  "--format", "json",
  "--mode", "basic",
  "--timeout", "20",
  "--skip-questionnaire",
  "--no-evolution",
];

function runCli(args, timeoutMs = 180_000) {
  return new Promise((resolve) => {
    const child = spawn("node", [SCRIPT_FILE, ...args], {
      cwd: SKILL_ROOT,
      env: process.env,
      stdio: ["ignore", "pipe", "pipe"],
    });
    let stdout = "";
    let stderr = "";
    child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
    child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
    const timer = setTimeout(() => {
      child.kill("SIGTERM");
      resolve({ ok: false, code: -1, stdout, stderr: `${stderr}\nTIMEOUT` });
    }, timeoutMs);
    child.on("close", (code) => {
      clearTimeout(timer);
      resolve({ ok: code === 0, code, stdout, stderr });
    });
    child.on("error", (error) => {
      clearTimeout(timer);
      resolve({ ok: false, code: -1, stdout, stderr: error.message });
    });
  });
}

function parseJsonFromOutput(output) {
  const start = output.indexOf("{");
  const end = output.lastIndexOf("}");
  if (start === -1 || end === -1 || end < start) return null;
  try {
    return JSON.parse(output.slice(start, end + 1));
  } catch {
    return null;
  }
}

function hasAnyPrice(data) {
  if (!data || typeof data !== "object") return false;
  const capabilities = ["quote", "fundamentals", "technicals", "sentiment"];
  for (const cap of capabilities) {
    const parsed = data[cap]?.parsed;
    if (!parsed) continue;
    if (parsed.price != null && parsed.price !== "N/A") return true;
    if (parsed.close != null) return true;
    if (parsed.lastPrice != null) return true;
  }
  return false;
}

test("analyze US ticker (AAPL) should return valid JSON structure", async (t) => {
  if (!process.env.QVERIS_API_KEY) {
    t.skip("QVERIS_API_KEY not set, skip live analyze e2e");
    return;
  }
  const run = await runCli(["analyze", "--symbol", "AAPL", "--market", "US", ...BASE_ARGS]);
  assert.equal(run.ok, true, `analyze AAPL failed: ${run.stderr}`);
  const result = parseJsonFromOutput(run.stdout);
  assert.ok(result && typeof result === "object", "output should be valid JSON");
  // mode is the analysis mode (basic/comprehensive/etc), not the command name
  assert.ok(result.mode, "mode should be present");
  assert.ok(result.symbol, "symbol should be present");
  assert.ok(result.symbol.toUpperCase().includes("AAPL"), `symbol should contain AAPL, got: ${result.symbol}`);
  assert.equal(result.market, "US");
  assert.ok(result.data && typeof result.data === "object", "data should be present");
  assert.ok(hasAnyPrice(result.data), "at least one capability should return a price");
  assert.ok(result.analysis && typeof result.analysis === "object", "analysis block should exist");
  assert.ok(result.runtime?.resolvedInput?.symbol, "runtime.resolvedInput.symbol should be set");
  assert.ok(result.meta?.guide, "meta.guide should be set for OpenClaw");
});

test("analyze HK ticker (0700.HK) should return valid JSON structure", async (t) => {
  if (!process.env.QVERIS_API_KEY) {
    t.skip("QVERIS_API_KEY not set, skip live analyze e2e");
    return;
  }
  const run = await runCli(["analyze", "--symbol", "0700.HK", "--market", "HK", ...BASE_ARGS]);
  assert.equal(run.ok, true, `analyze 0700.HK failed: ${run.stderr}`);
  const result = parseJsonFromOutput(run.stdout);
  assert.ok(result && typeof result === "object", "output should be valid JSON");
  assert.ok(result.mode, "mode should be present");
  assert.ok(result.symbol, "symbol should be present");
  const sym = result.symbol.toUpperCase().replace(/^0+/, "");
  assert.ok(sym.includes("700"), `symbol should contain 700, got: ${result.symbol}`);
  assert.ok(result.data && typeof result.data === "object", "data should be present");
  // At least one capability should succeed; HK data sources may degrade gracefully
  const capValues = Object.values(result.data);
  assert.ok(capValues.length >= 1, "data should have at least one capability");
});

test("analyze CN alias (腾讯控股) should resolve to 0700.HK", async (t) => {
  if (!process.env.QVERIS_API_KEY) {
    t.skip("QVERIS_API_KEY not set, skip live analyze e2e");
    return;
  }
  const run = await runCli(["analyze", "--symbol", "腾讯控股", "--market", "HK", ...BASE_ARGS]);
  assert.equal(run.ok, true, `analyze 腾讯控股 failed: ${run.stderr}`);
  const result = parseJsonFromOutput(run.stdout);
  assert.ok(result && typeof result === "object", "output should be valid JSON");
  assert.ok(result.mode, "mode should be present");
  const resolvedSymbol = result.runtime?.resolvedInput?.symbol || result.symbol || "";
  const normalized = resolvedSymbol.toUpperCase().replace(/^0+/, "");
  assert.ok(
    normalized.includes("700") || normalized.includes("TENCENT"),
    `resolved symbol should map to 0700.HK, got: ${resolvedSymbol}`,
  );
});

test("analyze --format chat (NVDA) should return segmented chat output", async (t) => {
  if (!process.env.QVERIS_API_KEY) {
    t.skip("QVERIS_API_KEY not set, skip live analyze e2e");
    return;
  }
  const run = await runCli([
    "analyze", "--symbol", "NVDA", "--market", "US",
    "--format", "chat",
    "--mode", "basic",
    "--timeout", "20",
    "--skip-questionnaire",
    "--no-evolution",
  ]);
  assert.equal(run.ok, true, `analyze --format chat failed: ${run.stderr}`);
  const result = parseJsonFromOutput(run.stdout);
  assert.ok(result && typeof result === "object", "chat output should be valid JSON");
  assert.ok(result.symbol, "chat output should have symbol");
  assert.ok(Array.isArray(result.segments), "chat output should have segments array");
  assert.ok(result.segments.length >= 1, "segments should not be empty");
});


path: tests/architecture-modules.test.mjs
content: import test from "node:test";
import assert from "node:assert/strict";

import { applyCapabilityDegradation } from "../scripts/lib/data/fallback.mjs";
import { resolveToolPayload, resolveToolPayloadSync } from "../scripts/lib/infra/qveris-client.mjs";
import { formatForChat } from "../scripts/lib/output/chat/formatter.mjs";
import { buildDecisionCard } from "../scripts/lib/output/decision-card.mjs";

test("HK fundamentals degradation should add warning", () => {
  const parsed = { revenue: null, netProfit: null, operatingCashflow: null, pe: 18.2 };
  const result = applyCapabilityDegradation("fundamentals", "HK", parsed);
  assert.ok(Array.isArray(result.warnings));
  assert.equal(result.warnings.length, 1);
  assert.equal(result.parsed.pe, 18.2);
});

test("chat formatter returns segmented output", () => {
  const sample = {
    symbol: "AAPL",
    market: "US",
    data: {
      quote: { parsed: { price: 200, percentChange: 1.2, volume: 1000 } },
      fundamentals: { parsed: { pe: 30, pb: 10, revenue: 100, netProfit: 20 } },
      sentiment: { parsed: { itemCount: 5 } },
      x_sentiment: { parsed: { itemCount: 8 } },
    },
    analysis: {
      scorecard: { grade: "B+" },
      recommendation: { signal: "买入" },
      chaseRisk: { risk: "low" },
    },
  };
  const chat = formatForChat(sample);
  assert.ok(chat.segments.length >= 3);
  assert.equal(chat.symbol, "AAPL");
});

test("decision card should output light signal text", () => {
  const sample = {
    symbol: "NVDA",
    market: "US",
    analysis: {
      scorecard: { composite: 81, grade: "B+" },
      recommendation: { signal: "买入" },
      chaseRisk: { risk: "medium" },
    },
  };
  const card = buildDecisionCard(sample);
  assert.ok(card.asText.includes("NVDA"));
  assert.equal(card.light, "偏积极");
});

test("resolveToolPayloadSync should parse truncated_content JSON", () => {
  const raw = {
    result: {
      truncated_content: "{\"items\":[1,2,3]}",
      full_content_file_url: "https://example.com/full.json",
    },
  };
  const resolved = resolveToolPayloadSync(raw);
  assert.equal(resolved.meta.contentMode, "truncated_content");
  assert.equal(resolved.meta.hasTruncatedContent, true);
  assert.deepEqual(resolved.content, { items: [1, 2, 3] });
});

test("resolveToolPayload should fallback to full_content_file_url when truncated invalid", async () => {
  const raw = {
    result: {
      truncated_content: "{\"items\":[1,2,3}",
      full_content_file_url: "https://qveris.ai/mock/full.json",
    },
  };
  const fakeFetch = async () => ({
    ok: true,
    async json() {
      return { items: [9, 8, 7] };
    },
  });
  const resolved = await resolveToolPayload(raw, { fetchImpl: fakeFetch, timeoutMs: 1000 });
  assert.equal(resolved.meta.contentMode, "full_content_file_url");
  assert.equal(resolved.meta.hasTruncatedContent, true);
  assert.deepEqual(resolved.content, { items: [9, 8, 7] });
});

test("resolveToolPayload should block non-qveris full_content_file_url host", async () => {
  const raw = {
    result: {
      truncated_content: "{\"items\":[1,2,3}",
      full_content_file_url: "https://example.com/full.json",
    },
  };
  let called = false;
  const fakeFetch = async () => {
    called = true;
    return { ok: true, async json() { return { items: [0] }; } };
  };
  const resolved = await resolveToolPayload(raw, { fetchImpl: fakeFetch, timeoutMs: 1000 });
  assert.equal(called, false);
  assert.equal(resolved.meta.contentMode, "payload");
  assert.equal(resolved.meta.hasTruncatedContent, true);
  assert.match(String(resolved.meta.fetchError || ""), /blocked host/i);
});


path: tests/brief.e2e.test.mjs
content: import test from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SKILL_ROOT = path.resolve(__dirname, "..");
const SCRIPT_FILE = path.join(SKILL_ROOT, "scripts", "stock_copilot_pro.mjs");
const WATCHLIST_FILE = path.join(SKILL_ROOT, "config", "watchlist.json");

function runCli(args, timeoutMs = 150_000) {
  return new Promise((resolve) => {
    const child = spawn("node", [SCRIPT_FILE, ...args], {
      cwd: SKILL_ROOT,
      env: process.env,
      stdio: ["ignore", "pipe", "pipe"],
    });
    let stdout = "";
    let stderr = "";
    child.stdout.on("data", (chunk) => {
      stdout += chunk.toString();
    });
    child.stderr.on("data", (chunk) => {
      stderr += chunk.toString();
    });
    const timer = setTimeout(() => {
      child.kill("SIGTERM");
      resolve({ ok: false, code: -1, stdout, stderr: `${stderr}\nTIMEOUT` });
    }, timeoutMs);
    child.on("close", (code) => {
      clearTimeout(timer);
      resolve({ ok: code === 0, code, stdout, stderr });
    });
    child.on("error", (error) => {
      clearTimeout(timer);
      resolve({ ok: false, code: -1, stdout, stderr: error.message });
    });
  });
}

function parseJsonFromOutput(output) {
  const start = output.indexOf("{");
  const end = output.lastIndexOf("}");
  if (start === -1 || end === -1 || end < start) return null;
  try {
    return JSON.parse(output.slice(start, end + 1));
  } catch {
    return null;
  }
}

test("brief e2e should satisfy acceptance criteria", async (t) => {
  if (!process.env.QVERIS_API_KEY) {
    t.skip("QVERIS_API_KEY not set, skip live brief e2e");
    return;
  }
  const original = await fs.readFile(WATCHLIST_FILE, "utf-8");
  const fixture = {
    updatedAt: new Date().toISOString(),
    holdings: [{ symbol: "AAPL", market: "US" }],
    watchlist: [
      { symbol: "NVDA", market: "US" },
      { symbol: "0700.HK", market: "HK" },
    ],
  };
  await fs.writeFile(WATCHLIST_FILE, JSON.stringify(fixture, null, 2), "utf-8");
  t.after(async () => {
    await fs.writeFile(WATCHLIST_FILE, original, "utf-8");
  });

  const run = await runCli(
    ["brief", "--type", "morning", "--market", "GLOBAL", "--max-items", "3", "--format", "json", "--timeout", "25", "--no-evolution"],
    240_000,
  );
  assert.equal(run.ok, true, `brief command failed: ${run.stderr}`);
  const result = parseJsonFromOutput(run.stdout);
  assert.ok(result && typeof result === "object", "brief output should be valid json");
  assert.equal(result.mode, "brief");
  assert.ok(Array.isArray(result.marketOverview?.indices), "marketOverview.indices should exist");
  assert.ok(result.marketOverview.indices.length >= 1, "marketOverview.indices should not be empty");
  // Each index should have at minimum a name and symbol; price may be null when market is closed or API degrades
  const firstIndex = result.marketOverview.indices[0];
  assert.ok(firstIndex.name || firstIndex.symbol, "each index entry should have name or symbol");

  assert.ok(Array.isArray(result.reports) && result.reports.length >= 1, "reports should not be empty");
  const successfulReports = result.reports.filter((x) => !x.error);
  assert.ok(successfulReports.length >= 1, "should have at least one successful report");
  assert.ok(successfulReports.some((x) => x.grade && x.grade !== "N/A"), "at least one report should contain valid grade");
  assert.ok(successfulReports.some((x) => x.price != null), "at least one report should contain current price");

  assert.ok(Array.isArray(result.highlights), "highlights should exist");
  assert.ok(result.highlights.length >= 1, "highlights should not be empty");
  assert.ok(result.meta?.note, "brief meta.note should exist for OpenClaw guidance");
  assert.equal(result.meta?.guide, "Daily Brief Analysis Guide");
});


path: tests/compare.e2e.test.mjs
content: import test from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SKILL_ROOT = path.resolve(__dirname, "..");
const SCRIPT_FILE = path.join(SKILL_ROOT, "scripts", "stock_copilot_pro.mjs");

function runCli(args, timeoutMs = 300_000) {
  return new Promise((resolve) => {
    const child = spawn("node", [SCRIPT_FILE, ...args], {
      cwd: SKILL_ROOT,
      env: process.env,
      stdio: ["ignore", "pipe", "pipe"],
    });
    let stdout = "";
    let stderr = "";
    child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
    child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
    const timer = setTimeout(() => {
      child.kill("SIGTERM");
      resolve({ ok: false, code: -1, stdout, stderr: `${stderr}\nTIMEOUT` });
    }, timeoutMs);
    child.on("close", (code) => {
      clearTimeout(timer);
      resolve({ ok: code === 0, code, stdout, stderr });
    });
    child.on("error", (error) => {
      clearTimeout(timer);
      resolve({ ok: false, code: -1, stdout, stderr: error.message });
    });
  });
}

function parseJsonFromOutput(output) {
  const start = output.indexOf("{");
  const end = output.lastIndexOf("}");
  if (start === -1 || end === -1 || end < start) return null;
  try {
    return JSON.parse(output.slice(start, end + 1));
  } catch {
    return null;
  }
}

test("compare US stocks (AAPL vs MSFT) should return two reports in JSON", async (t) => {
  if (!process.env.QVERIS_API_KEY) {
    t.skip("QVERIS_API_KEY not set, skip live compare e2e");
    return;
  }
  const run = await runCli([
    "compare",
    "--symbols", "AAPL,MSFT",
    "--market", "US",
    "--format", "json",
    "--mode", "basic",
    "--timeout", "20",
    "--skip-questionnaire",
    "--no-evolution",
  ]);
  assert.equal(run.ok, true, `compare AAPL,MSFT failed: ${run.stderr}`);
  const result = parseJsonFromOutput(run.stdout);
  assert.ok(result && typeof result === "object", "compare output should be valid JSON");
  assert.equal(result.mode, "compare", "mode should be 'compare'");
  assert.ok(Array.isArray(result.reports), "reports should be an array");
  assert.equal(result.reports.length, 2, "should have exactly 2 reports");
  for (const report of result.reports) {
    assert.ok(report.symbol, `each report should have symbol, got: ${JSON.stringify(report)}`);
    assert.ok(report.market, "each report should have market");
    assert.ok(report.data && typeof report.data === "object", "each report should have data block");
  }
  const symbols = result.reports.map((r) => r.symbol.toUpperCase());
  assert.ok(symbols.some((s) => s.includes("AAPL")), `reports should contain AAPL, got: ${symbols}`);
  assert.ok(symbols.some((s) => s.includes("MSFT")), `reports should contain MSFT, got: ${symbols}`);
});

test("compare --format chat should return structured chat comparison", async (t) => {
  if (!process.env.QVERIS_API_KEY) {
    t.skip("QVERIS_API_KEY not set, skip live compare e2e");
    return;
  }
  const run = await runCli([
    "compare",
    "--symbols", "NVDA,AMD",
    "--market", "US",
    "--format", "chat",
    "--mode", "basic",
    "--timeout", "20",
    "--skip-questionnaire",
    "--no-evolution",
  ]);
  assert.equal(run.ok, true, `compare --format chat failed: ${run.stderr}`);
  const result = parseJsonFromOutput(run.stdout);
  assert.ok(result && typeof result === "object", "chat compare output should be valid JSON");
  assert.equal(result.mode, "compare-chat", "mode should be 'compare-chat'");
  assert.ok(Array.isArray(result.reports), "reports should be an array");
  assert.equal(result.reports.length, 2, "should have exactly 2 reports");
  for (const report of result.reports) {
    assert.ok(report.symbol, "each chat report should have symbol");
    assert.ok(report.decisionCard, "each chat report should have decisionCard");
    assert.ok(report.chat && Array.isArray(report.chat.segments), "each chat report should have chat.segments");
  }
});


path: tests/radar.e2e.test.mjs
content: import test from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SKILL_ROOT = path.resolve(__dirname, "..");
const SCRIPT_FILE = path.join(SKILL_ROOT, "scripts", "stock_copilot_pro.mjs");

function runCli(args, timeoutMs = 120_000) {
  return new Promise((resolve) => {
    const child = spawn("node", [SCRIPT_FILE, ...args], {
      cwd: SKILL_ROOT,
      env: process.env,
      stdio: ["ignore", "pipe", "pipe"],
    });
    let stdout = "";
    let stderr = "";
    child.stdout.on("data", (chunk) => {
      stdout += chunk.toString();
    });
    child.stderr.on("data", (chunk) => {
      stderr += chunk.toString();
    });
    const timer = setTimeout(() => {
      child.kill("SIGTERM");
      resolve({ ok: false, code: -1, stdout, stderr: `${stderr}\nTIMEOUT` });
    }, timeoutMs);
    child.on("close", (code) => {
      clearTimeout(timer);
      resolve({ ok: code === 0, code, stdout, stderr });
    });
    child.on("error", (error) => {
      clearTimeout(timer);
      resolve({ ok: false, code: -1, stdout, stderr: error.message });
    });
  });
}

function parseJsonFromOutput(output) {
  const start = output.indexOf("{");
  const end = output.lastIndexOf("}");
  if (start === -1 || end === -1 || end < start) return null;
  try {
    return JSON.parse(output.slice(start, end + 1));
  } catch {
    return null;
  }
}

test("radar e2e should satisfy acceptance criteria", async (t) => {
  if (!process.env.QVERIS_API_KEY) {
    t.skip("QVERIS_API_KEY not set, skip live radar e2e");
    return;
  }
  const run = await runCli(
    ["radar", "--market", "GLOBAL", "--limit", "10", "--format", "json", "--timeout", "25", "--no-evolution"],
    180_000,
  );
  assert.equal(run.ok, true, `radar command failed: ${run.stderr}`);
  const result = parseJsonFromOutput(run.stdout);
  assert.ok(result && typeof result === "object", "radar output should be valid json");
  assert.equal(result.mode, "radar");
  assert.ok(result.topicCount >= 1, "topicCount should be >= 1");
  assert.ok(Array.isArray(result.topics) && result.topics.length >= 1, "topics should not be empty");
  // Verify topic structure contains required fields for OpenClaw analysis
  const firstTopic = result.topics[0];
  assert.ok(firstTopic.title, "topic should have title");
  assert.ok(firstTopic.source, "topic should have source");
  // category may be null when source doesn't provide classification, but the field must exist
  assert.ok("category" in firstTopic, "topic should have category field (may be null)");
  // sentiment and tickers may be null for some sources but keys must be present
  assert.ok("sentiment" in firstTopic, "topic should have sentiment field (may be null)");
  assert.ok("tickers" in firstTopic, "topic should have tickers field (may be null)");
  // Verify meta structure
  assert.ok(Array.isArray(result.meta?.sourceStats), "meta.sourceStats should exist");
  // At least one source must have been attempted
  assert.ok(result.meta.sourceStats.length >= 1, "should attempt at least one source");
  assert.ok(result.meta.note, "meta should contain note about OpenClaw analysis");
});


path: tests/regression-dataset.json
content: {
  "description": "Regression test dataset for stock-copilot-pro. Samples marked 'dynamic_name' are NOT in COMPANY_SYMBOL_ALIASES.",
  "version": "1.0.0",
  "samples": [
    {
      "id": "cn-teruide",
      "input": "特锐德",
      "expected_symbol_pattern": "300001.SZ",
      "market": "CN",
      "category": "dynamic_name",
      "required_fields": ["price", "name"]
    },
    {
      "id": "cn-ningde",
      "input": "宁德时代",
      "expected_symbol_pattern": "300750.SZ",
      "market": "CN",
      "category": "dynamic_name",
      "required_fields": ["price", "name"]
    },
    {
      "id": "cn-byd",
      "input": "比亚迪",
      "expected_symbol_pattern": "002594.SZ",
      "market": "CN",
      "category": "dynamic_name",
      "required_fields": ["price", "name"]
    },
    {
      "id": "cn-kweichow-moutai",
      "input": "贵州茅台",
      "expected_symbol_pattern": "600519.SH",
      "market": "CN",
      "category": "alias",
      "required_fields": ["price", "name"]
    },
    {
      "id": "cn-tbea",
      "input": "特变电工",
      "expected_symbol_pattern": "600089.SH",
      "market": "CN",
      "category": "alias",
      "required_fields": ["price", "name"]
    },
    {
      "id": "cn-code-direct",
      "input": "600519",
      "expected_symbol_pattern": "600519",
      "market": "CN",
      "category": "ticker",
      "required_fields": ["price"]
    },
    {
      "id": "hk-tencent",
      "input": "腾讯控股",
      "expected_symbol_pattern": "0700.HK",
      "market": "HK",
      "category": "alias",
      "required_fields": ["price", "name"]
    },
    {
      "id": "hk-code-direct",
      "input": "0700.HK",
      "expected_symbol_pattern": "0700.HK",
      "market": "HK",
      "category": "ticker",
      "required_fields": ["price"]
    },
    {
      "id": "hk-meituan",
      "input": "美团",
      "expected_symbol_pattern": "03690.HK",
      "market": "HK",
      "category": "dynamic_name",
      "required_fields": ["price", "name"],
      "allow_partial": true
    },
    {
      "id": "hk-xiaomi",
      "input": "小米集团",
      "expected_symbol_pattern": "01810.HK",
      "market": "HK",
      "category": "dynamic_name",
      "required_fields": ["price", "name"],
      "allow_partial": true
    },
    {
      "id": "us-nvidia",
      "input": "NVDA",
      "expected_symbol_pattern": "NVDA",
      "market": "US",
      "category": "ticker",
      "required_fields": ["price"]
    },
    {
      "id": "us-apple",
      "input": "AAPL",
      "expected_symbol_pattern": "AAPL",
      "market": "US",
      "category": "ticker",
      "required_fields": ["price"]
    },
    {
      "id": "us-tesla",
      "input": "TSLA",
      "expected_symbol_pattern": "TSLA",
      "market": "US",
      "category": "ticker",
      "required_fields": ["price"]
    }
  ]
}


path: tests/regression.test.mjs
content: #!/usr/bin/env node

/**
 * Regression Test Runner for stock-copilot-pro
 *
 * Runs batch analysis on the regression dataset and asserts that:
 * 1. Symbol resolution succeeds (no raw Chinese name passthrough)
 * 2. Required fields have valid data (not null/N/A)
 *
 * Usage:
 *   node tests/regression.test.mjs [--suite smoke|full] [--concurrency N] [--allow-degraded] [--verbose]
 *
 * Options:
 *   --suite          smoke|full (default: full)
 *   --concurrency    Parallel workers (default: 4)
 *   --analysis-mode  basic|fundamental|technical|comprehensive (default: basic)
 *   --timeout-sec    Per-analysis timeout in seconds (default: 10)
 *   --limit          Search result limit per capability (default: 5)
 *   --quick          Backward compatibility, equivalent to --suite smoke
 *   --allow-degraded Treat symbol/field mismatches as DEGRADED_PASS
 *   --verbose        Print detailed output for each sample
 */

import { spawn } from "node:child_process";
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SKILL_ROOT = path.resolve(__dirname, "..");
const DATASET_FILE = path.join(__dirname, "regression-dataset.json");
const SCRIPT_FILE = path.join(SKILL_ROOT, "scripts", "stock_copilot_pro.mjs");

const DEFAULT_TIMEOUT_SEC = 10;
const DEFAULT_CONCURRENCY = 4;
const DEFAULT_SUITE = "full";
const DEFAULT_ANALYSIS_MODE = "basic";
const DEFAULT_LIMIT = 5;
const SMOKE_SAMPLE_IDS = ["cn-teruide", "hk-tencent", "us-apple", "cn-byd", "hk-code-direct"];

function parseArgs() {
  const args = process.argv.slice(2);
  const getArgValue = (key, fallback) => {
    const idx = args.indexOf(key);
    if (idx === -1 || idx + 1 >= args.length) return fallback;
    return args[idx + 1];
  };
  const quick = args.includes("--quick");
  const suite = quick ? "smoke" : getArgValue("--suite", DEFAULT_SUITE);
  const concurrency = Math.max(1, Number(getArgValue("--concurrency", String(DEFAULT_CONCURRENCY))) || DEFAULT_CONCURRENCY);
  const analysisMode = getArgValue("--analysis-mode", DEFAULT_ANALYSIS_MODE);
  const timeoutSec = Math.max(3, Number(getArgValue("--timeout-sec", String(DEFAULT_TIMEOUT_SEC))) || DEFAULT_TIMEOUT_SEC);
  const limit = Math.max(1, Number(getArgValue("--limit", String(DEFAULT_LIMIT))) || DEFAULT_LIMIT);
  return {
    suite: suite === "smoke" ? "smoke" : "full",
    concurrency,
    analysisMode,
    timeoutSec,
    limit,
    allowDegraded: args.includes("--allow-degraded"),
    verbose: args.includes("--verbose"),
  };
}

async function loadDataset() {
  const raw = await fs.readFile(DATASET_FILE, "utf-8");
  return JSON.parse(raw);
}

function runAnalysis(sample, options) {
  // Process timeout must be significantly larger than per-tool timeout.
  // In `basic` mode there are 2 capabilities, each may try multiple tools.
  const capabilityFactor = options.analysisMode === "basic" ? 6 : 10;
  const timeoutMs = Math.max(25_000, options.timeoutSec * 1000 * capabilityFactor);
  const cmdArgs = [
    SCRIPT_FILE,
    "analyze",
    "--symbol",
    sample.input,
    "--market",
    sample.market || "GLOBAL",
    "--format",
    "json",
    "--mode",
    options.analysisMode,
    "--timeout",
    String(options.timeoutSec),
    "--limit",
    String(options.limit),
    "--skip-questionnaire",
    "--no-evolution",
  ];
  return new Promise((resolve) => {
    const child = spawn("node", cmdArgs, {
      cwd: SKILL_ROOT,
      env: process.env,
      stdio: ["ignore", "pipe", options.verbose ? "inherit" : "pipe"],
    });

    let stdout = "";
    child.stdout.on("data", (chunk) => {
      stdout += chunk.toString();
    });

    const timer = setTimeout(() => {
      child.kill("SIGTERM");
      resolve({ success: false, error: "timeout", stdout: "" });
    }, timeoutMs);

    child.on("close", (code) => {
      clearTimeout(timer);
      if (code !== 0) {
        resolve({ success: false, error: `exit code ${code}`, stdout });
      } else {
        resolve({ success: true, stdout });
      }
    });

    child.on("error", (err) => {
      clearTimeout(timer);
      resolve({ success: false, error: err.message, stdout: "" });
    });
  });
}

function parseResult(stdout) {
  try {
    // Find the JSON object in stdout (may have console.error logs before it)
    const jsonStart = stdout.indexOf("{");
    const jsonEnd = stdout.lastIndexOf("}");
    if (jsonStart === -1 || jsonEnd === -1) return null;
    const jsonStr = stdout.slice(jsonStart, jsonEnd + 1);
    return JSON.parse(jsonStr);
  } catch {
    return null;
  }
}

function checkFieldHasValue(result, fieldPath) {
  const parts = fieldPath.split(".");
  let val = result;
  for (const p of parts) {
    if (val == null || typeof val !== "object") return false;
    val = val[p];
  }
  if (val == null) return false;
  if (typeof val === "string" && (val.trim() === "" || val.toLowerCase() === "n/a")) return false;
  return true;
}

function checkSymbolResolution(result, expectedPattern) {
  // Check that the resolved symbol matches expected pattern
  // JSON output has symbol at root level
  const symbol = result?.symbol;
  if (!symbol) return { ok: false, reason: "no symbol in result" };
  // Check if symbol contains the expected pattern (e.g. "300001.SZ" contains "300001.SZ")
  // or matches exactly
  const normalizedSymbol = symbol.toUpperCase().replace(/^0+/, "");
  const normalizedExpected = expectedPattern.toUpperCase().replace(/^0+/, "");
  if (!normalizedSymbol.includes(normalizedExpected) && normalizedSymbol !== normalizedExpected) {
    return { ok: false, reason: `symbol mismatch: got "${symbol}", expected pattern "${expectedPattern}"` };
  }
  return { ok: true };
}

function checkRequiredFields(result, requiredFields) {
  const missing = [];
  for (const field of requiredFields) {
    // Map shorthand field names to actual JSON paths
    // JSON structure: { symbol, market, data: { quote: { parsed: { price, ... } }, fundamentals: { parsed: { name, ... } } } }
    const paths = {
      price: ["data.quote.parsed.price", "data.fundamentals.parsed.price"],
      name: ["data.fundamentals.parsed.name", "data.fundamentals.parsed.companyName"],
    };
    const candidates = paths[field] || [field];
    const found = candidates.some((p) => checkFieldHasValue(result, p));
    if (!found) missing.push(field);
  }
  return missing;
}

async function main() {
  if (!process.env.QVERIS_API_KEY) {
    console.log("QVERIS_API_KEY is not set, skip live regression suite.");
    process.exit(0);
  }
  const opts = parseArgs();
  const dataset = await loadDataset();
  let samples = dataset.samples;
  const requiredMarkets = ["CN", "HK", "US"];
  const coveredMarkets = new Set((samples || []).map((s) => String(s.market || "").toUpperCase()));
  const uncovered = requiredMarkets.filter((m) => !coveredMarkets.has(m));
  if (uncovered.length > 0) {
    console.log(`Dataset missing required market coverage: ${uncovered.join(", ")}`);
    process.exit(1);
  }

  if (opts.suite === "smoke") {
    const smoke = samples.filter((s) => SMOKE_SAMPLE_IDS.includes(s.id));
    samples = smoke.length > 0 ? smoke : samples.slice(0, 5);
  }
  console.log(
    `Running ${samples.length} samples | suite=${opts.suite} | mode=${opts.analysisMode} | concurrency=${opts.concurrency} | timeout=${opts.timeoutSec}s | limit=${opts.limit}`,
  );

  const results = [];
  let passed = 0;
  let failed = 0;
  let cursor = 0;
  const marketStats = { CN: { passed: 0, failed: 0 }, HK: { passed: 0, failed: 0 }, US: { passed: 0, failed: 0 } };

  async function runOne(sample) {
    const startTime = Date.now();
    console.log(`\n--- [${sample.id}] Testing: "${sample.input}" (${sample.category}) ---`);

    const runResult = await runAnalysis(sample, opts);
    const elapsed = Date.now() - startTime;

    if (!runResult.success) {
      if (opts.allowDegraded) {
        console.log(`  ⚠️ DEGRADED_PASS: ${runResult.error}`);
        return { ...sample, status: "DEGRADED_PASS", reason: runResult.error, elapsed };
      }
      console.log(`  ❌ FAILED: ${runResult.error}`);
      return { ...sample, status: "FAILED", reason: runResult.error, elapsed };
    }

    const parsed = parseResult(runResult.stdout);
    if (!parsed) {
      console.log(`  ❌ FAILED: could not parse JSON output`);
      return { ...sample, status: "FAILED", reason: "parse error", elapsed };
    }

    if (opts.verbose) {
      console.log("  Parsed result keys:", Object.keys(parsed));
    }

    // Check symbol resolution
    const symbolCheck = checkSymbolResolution(parsed, sample.expected_symbol_pattern);
    if (!symbolCheck.ok) {
      if (opts.allowDegraded || sample.allow_partial) {
        console.log(`  ⚠️ DEGRADED_PASS: ${symbolCheck.reason}`);
        return { ...sample, status: "DEGRADED_PASS", reason: symbolCheck.reason, elapsed };
      }
      console.log(`  ❌ FAILED: ${symbolCheck.reason}`);
      return { ...sample, status: "FAILED", reason: symbolCheck.reason, elapsed };
    }

    // Check required fields
    const missingFields = checkRequiredFields(parsed, sample.required_fields);
    if (missingFields.length > 0) {
      if (opts.allowDegraded || sample.allow_partial) {
        console.log(`  ⚠️ DEGRADED_PASS: missing fields: ${missingFields.join(", ")}`);
        return { ...sample, status: "DEGRADED_PASS", missing: missingFields, elapsed };
      }
      console.log(`  ❌ FAILED: missing fields: ${missingFields.join(", ")}`);
      return { ...sample, status: "FAILED", reason: "missing required fields", missing: missingFields, elapsed };
    }

    console.log(`  ✅ PASSED (${elapsed}ms)`);
    return { ...sample, status: "PASSED", elapsed };
  }

  async function worker() {
    while (true) {
      const idx = cursor++;
      if (idx >= samples.length) return;
      const sample = samples[idx];
      const r = await runOne(sample);
      results.push(r);
      const marketKey = String(sample.market || "GLOBAL").toUpperCase();
      const isPass = r.status === "PASSED" || r.status === "DEGRADED_PASS";
      if (isPass) {
        passed++;
        if (marketStats[marketKey]) marketStats[marketKey].passed++;
      } else {
        failed++;
        if (marketStats[marketKey]) marketStats[marketKey].failed++;
      }
    }
  }
  await Promise.all(Array.from({ length: Math.min(opts.concurrency, samples.length) }, () => worker()));

  // Summary
  console.log("\n" + "=".repeat(60));
  console.log("REGRESSION TEST SUMMARY");
  console.log("=".repeat(60));
  console.log(`Total: ${samples.length} | Passed: ${passed} | Failed: ${failed}`);
  console.log(
    `By market -> CN: ${marketStats.CN.passed}/${marketStats.CN.passed + marketStats.CN.failed}, HK: ${marketStats.HK.passed}/${marketStats.HK.passed + marketStats.HK.failed}, US: ${marketStats.US.passed}/${marketStats.US.passed + marketStats.US.failed}`,
  );
  console.log();

  const failedResults = results.filter((r) => r.status !== "PASSED" && r.status !== "DEGRADED_PASS");
  if (failedResults.length > 0) {
    console.log("Failed samples:");
    for (const r of failedResults) {
      console.log(`  - [${r.id}] ${r.input}: ${r.reason || r.status}${r.missing ? ` (missing: ${r.missing.join(", ")})` : ""}`);
    }
  }

  // Exit with error if any failures
  if (failed > 0) {
    console.log("\n❌ Regression test FAILED");
    process.exit(1);
  } else {
    console.log("\n✅ All regression tests PASSED");
    process.exit(0);
  }
}

main().catch((err) => {
  console.error("Regression test error:", err);
  process.exit(1);
});


path: tests/watch.e2e.test.mjs
content: import test from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SKILL_ROOT = path.resolve(__dirname, "..");
const SCRIPT_FILE = path.join(SKILL_ROOT, "scripts", "stock_copilot_pro.mjs");
const WATCHLIST_FILE = path.join(SKILL_ROOT, "config", "watchlist.json");

function runCli(args, timeoutMs = 30_000) {
  return new Promise((resolve) => {
    const child = spawn("node", [SCRIPT_FILE, ...args], {
      cwd: SKILL_ROOT,
      env: process.env,
      stdio: ["ignore", "pipe", "pipe"],
    });
    let stdout = "";
    let stderr = "";
    child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
    child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
    const timer = setTimeout(() => {
      child.kill("SIGTERM");
      resolve({ ok: false, code: -1, stdout, stderr: `${stderr}\nTIMEOUT` });
    }, timeoutMs);
    child.on("close", (code) => {
      clearTimeout(timer);
      resolve({ ok: code === 0, code, stdout, stderr });
    });
    child.on("error", (error) => {
      clearTimeout(timer);
      resolve({ ok: false, code: -1, stdout, stderr: error.message });
    });
  });
}

function parseJsonFromOutput(output) {
  const start = output.indexOf("{");
  const end = output.lastIndexOf("}");
  if (start === -1 || end === -1 || end < start) return null;
  try {
    return JSON.parse(output.slice(start, end + 1));
  } catch {
    return null;
  }
}

test("watch list should return JSON with holdings and watchlist arrays", async (t) => {
  const run = await runCli(["watch", "--action", "list", "--format", "json"]);
  assert.equal(run.ok, true, `watch list failed: ${run.stderr}`);
  const result = parseJsonFromOutput(run.stdout);
  assert.ok(result && typeof result === "object", "watch list output should be valid JSON");
  assert.equal(result.mode, "watch", "mode should be 'watch'");
  assert.equal(result.action, "list", "action should be 'list'");
  assert.ok(result.watchlist && typeof result.watchlist === "object", "watchlist state should exist");
  assert.ok(Array.isArray(result.watchlist.holdings), "watchlist.holdings should be an array");
  assert.ok(Array.isArray(result.watchlist.watchlist), "watchlist.watchlist should be an array");
});

test("watch add + remove lifecycle should maintain consistency", async (t) => {
  const original = await fs.readFile(WATCHLIST_FILE, "utf-8");
  t.after(async () => {
    await fs.writeFile(WATCHLIST_FILE, original, "utf-8");
  });

  const testSymbol = "TSLA";
  const testMarket = "US";

  // Add TSLA to watchlist
  const addRun = await runCli([
    "watch", "--action", "add",
    "--symbol", testSymbol,
    "--market", testMarket,
    "--bucket", "watchlist",
    "--format", "json",
  ]);
  assert.equal(addRun.ok, true, `watch add failed: ${addRun.stderr}`);
  const addResult = parseJsonFromOutput(addRun.stdout);
  assert.ok(addResult && typeof addResult === "object", "add result should be valid JSON");
  assert.equal(addResult.mode, "watch");
  assert.equal(addResult.action, "add");
  assert.equal(addResult.symbol, testSymbol);

  // Verify the symbol is now present in the watchlist
  const afterAdd = addResult.watchlist?.watchlist || [];
  const found = afterAdd.some(
    (item) => item.symbol.toUpperCase() === testSymbol && item.market === testMarket,
  );
  assert.ok(found, `${testSymbol} should appear in watchlist after add`);

  // Remove TSLA from watchlist
  const removeRun = await runCli([
    "watch", "--action", "remove",
    "--symbol", testSymbol,
    "--market", testMarket,
    "--bucket", "watchlist",
    "--format", "json",
  ]);
  assert.equal(removeRun.ok, true, `watch remove failed: ${removeRun.stderr}`);
  const removeResult = parseJsonFromOutput(removeRun.stdout);
  assert.ok(removeResult && typeof removeResult === "object", "remove result should be valid JSON");
  assert.equal(removeResult.mode, "watch");
  assert.equal(removeResult.action, "remove");

  // Verify the symbol is gone
  const afterRemove = removeResult.watchlist?.watchlist || [];
  const stillPresent = afterRemove.some(
    (item) => item.symbol.toUpperCase() === testSymbol && item.market === testMarket,
  );
  assert.ok(!stillPresent, `${testSymbol} should be removed from watchlist after remove`);
});

test("watch add to holdings bucket should persist separately from watchlist", async (t) => {
  const original = await fs.readFile(WATCHLIST_FILE, "utf-8");
  t.after(async () => {
    await fs.writeFile(WATCHLIST_FILE, original, "utf-8");
  });

  const testSymbol = "AAPL";
  const testMarket = "US";

  const addRun = await runCli([
    "watch", "--action", "add",
    "--symbol", testSymbol,
    "--market", testMarket,
    "--bucket", "holdings",
    "--format", "json",
  ]);
  assert.equal(addRun.ok, true, `watch add to holdings failed: ${addRun.stderr}`);
  const addResult = parseJsonFromOutput(addRun.stdout);
  assert.ok(addResult && typeof addResult === "object", "result should be valid JSON");
  assert.equal(addResult.bucket, "holdings");

  const holdingsList = addResult.watchlist?.holdings || [];
  const found = holdingsList.some(
    (item) => item.symbol.toUpperCase() === testSymbol,
  );
  assert.ok(found, `${testSymbol} should appear in holdings after add`);
});


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: clean (LOW)