
Stock Copilot Pro
- 247 installs
- 20 repo stars
- Updated August 4, 2026
- qverisai/open-qveris-skills
Real-time stock analysis and portfolio monitoring for traders and investors
About
Stock Copilot Pro is an AI-powered financial analysis agent that provides real-time stock insights and portfolio monitoring. Use it when you need intelligent, data-driven stock analysis and trading recommendations at your fingertips.
- Stock market data integration
- Real-time analysis
- Portfolio tracking
- AI-powered insights
Stock Copilot Pro by the numbers
- 247 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #366 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/qverisai/open-qveris-skills --skill stock-copilot-proAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 247 |
|---|---|
| repo stars | ★ 20 |
| Last updated | August 4, 2026 |
| Repository | qverisai/open-qveris-skills ↗ |
What it does
Real-time stock analysis and portfolio monitoring for traders and investors
Files
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_quotationths_ifind.financial_statementsths_ifind.company_basicsths_ifind.history_quotation- CN/HK news and research:
caidazi.news.querycaidazi.report.querycaidazi.search.hybrid.listcaidazi.search.hybrid_v2.query- Global news sentiment:
alpha_news_sentimentfinnhub.news- X/Twitter sentiment and hot topics:
qveris_social.x_domain_hot_topicsqveris_social.x_domain_hot_eventsqveris_social.x_domain_new_postsx_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
caidazichannels (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
--evidenceis 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 comprehensivenode 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 listnode scripts/stock_copilot_pro.mjs watch --action add --bucket holdings --symbol AAPL --market USnode 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 chatnode 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
jobsarray inconfig/openclaw-cron.example.json; each item is onecron.addpayload (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.
scheduleas string,command,delivery.channelsarray) 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:
revenuenetProfittotalAssetstotalLiabilitiesoperatingCashflowindustrymainBusinesstags
Output Modes
markdown(default): human-readable reportjson: machine-readable merged payloadchat: segmented chat-friendly output for messaging appssummary-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_templatesandsample_successful_paramsfor reuse. - Evolution does not decide tool priority; tool priority is controlled by
tool-chains.json. - Use
--no-evolutionto disable loading/saving runtime learning state.
Safety and Disclosure
- Uses only
QVERIS_API_KEY. - Calls only QVeris APIs over HTTPS.
full_content_file_urlfetching is kept enabled for data completeness, but only HTTPS URLs underqveris.aiare 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 fromconfig/watchlist.example.json). - OpenClaw scheduled tasks: see
config/openclaw-cron.example.json. Create jobs with the official format (schedule.kind,payload.kind,sessionTarget, etc.) viaopenclaw cron addor 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). Setdelivery.channelanddelivery.tofor your channel (e.g. feishu). - External source URLs remain hidden by default; only shown when
--include-source-urlsis 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
datafields. - Include at least: price/change, marketCap, PE/PB, profitMargin, revenue, netProfit, RSI, 52W range.
- Example format:
| 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, ordata.*.selectedTool. - Example format:
> 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:00ZQuality 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
sourcelabel 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.
Evolution State
This directory is used for runtime self-learning state.
Files
tool-evolution.json: generated/updated byscripts/stock_copilot_pro.mjsrun*.json: optional local debug outputs (not required for release)
What It Stores
- Learned
tool_identries per capability bucket - Success/failure counters
- Average execution latency
- Bounded market tags
- Priority queue order for next runs
What It Must NOT Store
QVERIS_API_KEYAuthorizationheaderssearch_idand symbol samples- Raw full API payloads
- Any user secret or credential
Behavior
- If the file is missing or corrupted, the script auto-recovers with empty state.
- Learning policy is aggressive: one successful execution can enroll a tool into queue.
- State is sanitized and size-limited before every save.
- Use
--no-evolutionto run without reading/writing this directory. - For release upload, keep
tool-evolution.jsonif you want a pre-warmed experience.
{
"version": 2,
"updated_at": "2026-02-22T14:43:58.767Z",
"tools": {
"finnhub_io_api.stock.quote": {
"tool_id": "finnhub_io_api.stock.quote",
"provider": "finnhub_io_api",
"first_seen_at": "2026-02-15T09:47:32.971Z",
"last_success_at": "2026-02-15T09:53:26.668Z",
"success_count": 3,
"fail_count": 15,
"avg_elapsed_ms": 1354,
"markets": [
"CN",
"US",
"GLOBAL",
"HK"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"ths_ifind.real_time_quotation.v1": {
"tool_id": "ths_ifind.real_time_quotation.v1",
"provider": "ths_ifind",
"first_seen_at": "2026-02-15T09:55:37.921Z",
"last_success_at": "2026-02-16T10:55:14.838Z",
"success_count": 39,
"fail_count": 1,
"avg_elapsed_ms": 1314,
"markets": [
"CN",
"HK"
],
"param_templates": {
"CN": {
"codes": "{thsCode}"
},
"HK": {
"codes": "{thsCode}"
}
},
"sample_successful_params": {
"codes": "0700.HK"
},
"last_error": null
},
"twelvedata.quote.retrieve.v1.affbefe3": {
"tool_id": "twelvedata.quote.retrieve.v1.affbefe3",
"provider": "twelvedata",
"first_seen_at": "2026-02-15T09:27:04.326Z",
"last_success_at": "2026-02-22T14:17:44.676Z",
"success_count": 41,
"fail_count": 21,
"avg_elapsed_ms": 1066,
"markets": [
"US",
"CN",
"GLOBAL",
"HK"
],
"param_templates": {
"US": {
"symbol": "{caidaziTicker}"
}
},
"sample_successful_params": {
"symbol": "MSFT"
},
"last_error": null
},
"alphavantage.realtime_bulk_quotes.retrieve.v1.7aca3c4a": {
"tool_id": "alphavantage.realtime_bulk_quotes.retrieve.v1.7aca3c4a",
"provider": "alphavantage",
"first_seen_at": "2026-02-15T09:27:03.122Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 3,
"avg_elapsed_ms": 0,
"markets": [
"US",
"CN"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"alphavantage.time_series.intraday.retrieve.v1": {
"tool_id": "alphavantage.time_series.intraday.retrieve.v1",
"provider": "alphavantage",
"first_seen_at": "2026-02-15T03:22:49.607Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 33,
"avg_elapsed_ms": 0,
"markets": [
"US",
"HK",
"CN",
"GLOBAL"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"alphavantage.time-series.intraday.v1": {
"tool_id": "alphavantage.time-series.intraday.v1",
"provider": "alphavantage",
"first_seen_at": "2026-02-15T03:22:49.176Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 25,
"avg_elapsed_ms": 0,
"markets": [
"US",
"HK",
"CN"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"alphavantage.time_series_intraday.retrieve.v1.1e18340d": {
"tool_id": "alphavantage.time_series_intraday.retrieve.v1.1e18340d",
"provider": "alphavantage",
"first_seen_at": "2026-02-15T03:22:49.943Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 21,
"avg_elapsed_ms": 0,
"markets": [
"US",
"HK",
"CN"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"alphavantage.fundamentals.income_statement.retrieve.v1.7aca3c4a": {
"tool_id": "alphavantage.fundamentals.income_statement.retrieve.v1.7aca3c4a",
"provider": "alphavantage",
"first_seen_at": "2026-02-15T03:22:51.247Z",
"last_success_at": "2026-02-22T14:17:50.860Z",
"success_count": 52,
"fail_count": 19,
"avg_elapsed_ms": 1060,
"markets": [
"US",
"HK",
"CN",
"GLOBAL"
],
"param_templates": {
"US": {
"function": "OVERVIEW",
"symbol": "{caidaziTicker}"
}
},
"sample_successful_params": {
"function": "OVERVIEW",
"symbol": "MSFT"
},
"last_error": null
},
"ths_ifind.company_basics.v1": {
"tool_id": "ths_ifind.company_basics.v1",
"provider": "ths_ifind",
"first_seen_at": "2026-02-15T09:55:40.013Z",
"last_success_at": "2026-02-16T10:55:20.061Z",
"success_count": 10,
"fail_count": 0,
"avg_elapsed_ms": 1215,
"markets": [
"CN",
"HK"
],
"param_templates": {
"HK": {
"codes": "{thsCode}"
}
},
"sample_successful_params": {
"codes": "0700.HK"
},
"last_error": null
},
"ths_ifind.history_quotation.v1": {
"tool_id": "ths_ifind.history_quotation.v1",
"provider": "ths_ifind",
"first_seen_at": "2026-02-15T09:55:43.623Z",
"last_success_at": "2026-02-16T10:55:21.832Z",
"success_count": 39,
"fail_count": 1,
"avg_elapsed_ms": 1138,
"markets": [
"CN",
"HK"
],
"param_templates": {
"CN": {
"codes": "{thsCode}",
"startdate": "2026-01-02",
"enddate": "2026-02-16",
"interval": "D"
},
"HK": {
"codes": "{thsCode}",
"startdate": "2026-01-02",
"enddate": "2026-02-16",
"interval": "D"
}
},
"sample_successful_params": {
"codes": "0700.HK",
"startdate": "2026-01-02",
"enddate": "2026-02-16",
"interval": "D"
},
"last_error": null
},
"alphavantage.technical_indicators.t3.retrieve.v1.7aca3c4a": {
"tool_id": "alphavantage.technical_indicators.t3.retrieve.v1.7aca3c4a",
"provider": "alphavantage",
"first_seen_at": "2026-02-15T03:22:57.089Z",
"last_success_at": "2026-02-15T09:53:28.440Z",
"success_count": 15,
"fail_count": 0,
"avg_elapsed_ms": 2546,
"markets": [
"US",
"HK",
"CN"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"twelvedata.macdslope.retrieve.v1.a926750b": {
"tool_id": "twelvedata.macdslope.retrieve.v1.a926750b",
"provider": "twelvedata",
"first_seen_at": "2026-02-15T03:22:52.579Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 18,
"avg_elapsed_ms": 0,
"markets": [
"US",
"GLOBAL",
"HK"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"twelvedata.macd.retrieve.v1.9735a694": {
"tool_id": "twelvedata.macd.retrieve.v1.9735a694",
"provider": "twelvedata",
"first_seen_at": "2026-02-15T03:22:53.193Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 18,
"avg_elapsed_ms": 0,
"markets": [
"US",
"GLOBAL",
"HK"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"caidazi.report.query.v1.43d808fc": {
"tool_id": "caidazi.report.query.v1.43d808fc",
"provider": "caidazi",
"first_seen_at": "2026-02-15T10:32:30.413Z",
"last_success_at": "2026-02-15T10:32:30.413Z",
"success_count": 1,
"fail_count": 0,
"avg_elapsed_ms": 2650,
"markets": [
"CN"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"finnhub.news.retrieve.v1": {
"tool_id": "finnhub.news.retrieve.v1",
"provider": "finnhub",
"first_seen_at": "2026-02-15T09:53:39.027Z",
"last_success_at": "2026-02-16T10:55:26.754Z",
"success_count": 15,
"fail_count": 19,
"avg_elapsed_ms": 1964,
"markets": [
"CN",
"GLOBAL",
"HK"
],
"param_templates": {
"CN": {
"category": "general"
},
"HK": {
"category": "general"
},
"GLOBAL": {
"category": "general"
}
},
"sample_successful_params": {
"category": "general"
},
"last_error": null
},
"alphavantage.news_sentiment.query.v1.7aca3c4a": {
"tool_id": "alphavantage.news_sentiment.query.v1.7aca3c4a",
"provider": "alphavantage",
"first_seen_at": "2026-02-15T03:23:02.073Z",
"last_success_at": "2026-02-16T10:55:50.159Z",
"success_count": 5,
"fail_count": 18,
"avg_elapsed_ms": 1888,
"markets": [
"US",
"HK",
"CN",
"GLOBAL"
],
"param_templates": {
"US": {
"function": "NEWS_SENTIMENT",
"tickers": "{caidaziTicker}",
"sort": "LATEST",
"limit": 10
}
},
"sample_successful_params": {
"function": "NEWS_SENTIMENT",
"tickers": "AAPL",
"sort": "LATEST",
"limit": 10
},
"last_error": null
},
"caidazi.search.hybrid_v2.query.v1.b27f5d64": {
"tool_id": "caidazi.search.hybrid_v2.query.v1.b27f5d64",
"provider": "caidazi",
"first_seen_at": "2026-02-15T10:32:26.986Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 45,
"avg_elapsed_ms": 0,
"markets": [
"CN",
"HK"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"caidazi.news.query.v1.e76b9116": {
"tool_id": "caidazi.news.query.v1.e76b9116",
"provider": "caidazi",
"first_seen_at": "2026-02-15T10:32:27.762Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 1,
"avg_elapsed_ms": 0,
"markets": [
"CN"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"x_developer.2.tweets.search.recent.retrieve.v2.f424c8f9": {
"tool_id": "x_developer.2.tweets.search.recent.retrieve.v2.f424c8f9",
"provider": "x_developer",
"first_seen_at": "2026-02-15T03:25:29.361Z",
"last_success_at": "2026-02-22T14:43:58.759Z",
"success_count": 102,
"fail_count": 0,
"avg_elapsed_ms": 1045,
"markets": [
"US",
"HK",
"CN",
"GLOBAL"
],
"param_templates": {
"CN": {
"query": "(600089 OR $600089) lang:en -is:retweet",
"max_results": 20,
"sort_order": "relevancy"
},
"US": {
"query": "(AAPL OR $AAPL) lang:en -is:retweet",
"max_results": 20,
"sort_order": "relevancy"
},
"HK": {
"query": "(0700 OR $0700) lang:en -is:retweet",
"max_results": 20,
"sort_order": "relevancy"
},
"GLOBAL": {
"query": "(300750.SZ,3750.HK,CYATY.PQ OR $300750.SZ,3750.HK,CYATY.PQ) lang:en -is:retweet",
"max_results": 20,
"sort_order": "relevancy"
}
},
"sample_successful_params": {
"query": "(AAPL OR $AAPL) lang:en -is:retweet",
"max_results": 20,
"sort_order": "relevancy"
},
"last_error": null
},
"alphavantage.news_sentiment.query.v1.467a92c0": {
"tool_id": "alphavantage.news_sentiment.query.v1.467a92c0",
"provider": "alphavantage",
"first_seen_at": "2026-02-15T03:23:02.348Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 12,
"avg_elapsed_ms": 0,
"markets": [
"US",
"HK",
"CN"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"qveris_social.x_account_hottest_post_v1": {
"tool_id": "qveris_social.x_account_hottest_post_v1",
"provider": "qveris_social",
"first_seen_at": "2026-02-15T03:23:02.773Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 12,
"avg_elapsed_ms": 0,
"markets": [
"US",
"HK",
"CN"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"ths_ifind.financial_statements.v1": {
"tool_id": "ths_ifind.financial_statements.v1",
"provider": "ths_ifind",
"first_seen_at": "2026-02-15T11:17:24.684Z",
"last_success_at": "2026-02-16T10:54:57.653Z",
"success_count": 29,
"fail_count": 9,
"avg_elapsed_ms": 1153,
"markets": [
"CN",
"HK"
],
"param_templates": {
"CN": {
"statement_type": "income",
"codes": "{thsCode}",
"year": "{report.year}",
"period": "{report.period}",
"type": "1"
}
},
"sample_successful_params": {
"statement_type": "income",
"codes": "300001.SZ",
"year": "2025",
"period": "0930",
"type": "1"
},
"last_error": null
},
"finnhub.news.retrieve.v1.2e72ea5d": {
"tool_id": "finnhub.news.retrieve.v1.2e72ea5d",
"provider": "finnhub",
"first_seen_at": "2026-02-15T11:17:32.514Z",
"last_success_at": "2026-02-15T12:28:56.743Z",
"success_count": 16,
"fail_count": 18,
"avg_elapsed_ms": 2101,
"markets": [
"CN",
"US",
"HK",
"GLOBAL"
],
"param_templates": {
"CN": {
"category": "general"
},
"US": {
"category": "general"
},
"HK": {
"category": "general"
}
},
"sample_successful_params": {
"category": "general"
},
"last_error": null
},
"twelvedata.rsi.retrieve.v1.91d81651": {
"tool_id": "twelvedata.rsi.retrieve.v1.91d81651",
"provider": "twelvedata",
"first_seen_at": "2026-02-15T11:20:08.080Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 36,
"avg_elapsed_ms": 0,
"markets": [
"US"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"alphavantage.company.overview.retrieve.v1.467a92c0": {
"tool_id": "alphavantage.company.overview.retrieve.v1.467a92c0",
"provider": "alphavantage",
"first_seen_at": "2026-02-15T12:14:31.412Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 6,
"avg_elapsed_ms": 0,
"markets": [
"GLOBAL"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"twelvedata.statistics.retrieve.v1.af1559c2": {
"tool_id": "twelvedata.statistics.retrieve.v1.af1559c2",
"provider": "twelvedata",
"first_seen_at": "2026-02-15T12:14:32.390Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 6,
"avg_elapsed_ms": 0,
"markets": [
"GLOBAL"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"alphavantage.technical.macdext.retrieve.v1.7aca3c4a": {
"tool_id": "alphavantage.technical.macdext.retrieve.v1.7aca3c4a",
"provider": "alphavantage",
"first_seen_at": "2026-02-15T12:14:35.963Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 6,
"avg_elapsed_ms": 0,
"markets": [
"GLOBAL"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"ths_ifind.income_statement.v1": {
"tool_id": "ths_ifind.income_statement.v1",
"provider": "ths_ifind",
"first_seen_at": "2026-02-15T12:33:12.136Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 1,
"avg_elapsed_ms": 0,
"markets": [
"CN"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"ths_ifind.balance_sheet.v1": {
"tool_id": "ths_ifind.balance_sheet.v1",
"provider": "ths_ifind",
"first_seen_at": "2026-02-15T12:33:14.132Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 1,
"avg_elapsed_ms": 0,
"markets": [
"CN"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"finnhub.news.retrieve.v1.51b2567e": {
"tool_id": "finnhub.news.retrieve.v1.51b2567e",
"provider": "finnhub",
"first_seen_at": "2026-02-16T02:43:08.583Z",
"last_success_at": "2026-02-22T14:43:52.005Z",
"success_count": 39,
"fail_count": 0,
"avg_elapsed_ms": 2108,
"markets": [
"CN",
"US",
"HK",
"GLOBAL"
],
"param_templates": {
"CN": {
"category": "general"
},
"US": {
"category": "general"
},
"HK": {
"category": "general"
},
"GLOBAL": {
"category": "general"
}
},
"sample_successful_params": {
"category": "general"
},
"last_error": null
},
"financial_modeling_prep.historical_industry_performance.retrieve.v1.c9d85521": {
"tool_id": "financial_modeling_prep.historical_industry_performance.retrieve.v1.c9d85521",
"provider": "financial_modeling_prep",
"first_seen_at": "2026-02-16T10:34:48.746Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 2,
"avg_elapsed_ms": 0,
"markets": [
"US"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"financial_modeling_prep.custom_discounted_cash_flow.retrieve.v1.c1d2de7e": {
"tool_id": "financial_modeling_prep.custom_discounted_cash_flow.retrieve.v1.c1d2de7e",
"provider": "financial_modeling_prep",
"first_seen_at": "2026-02-16T10:34:50.086Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 7,
"avg_elapsed_ms": 0,
"markets": [
"US",
"GLOBAL"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"finnhub_io_api.company.basic_financials": {
"tool_id": "finnhub_io_api.company.basic_financials",
"provider": "finnhub_io_api",
"first_seen_at": "2026-02-16T10:34:50.553Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 2,
"avg_elapsed_ms": 0,
"markets": [
"US"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"financial_modeling_prep.stock.quote.retrieve.v1.822497ca": {
"tool_id": "financial_modeling_prep.stock.quote.retrieve.v1.822497ca",
"provider": "financial_modeling_prep",
"first_seen_at": "2026-02-22T12:33:34.506Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 5,
"avg_elapsed_ms": 0,
"markets": [
"HK"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"alphavantage.income_statement.retrieve.v1.7aca3c4a": {
"tool_id": "alphavantage.income_statement.retrieve.v1.7aca3c4a",
"provider": "alphavantage",
"first_seen_at": "2026-02-22T12:33:39.627Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 6,
"avg_elapsed_ms": 0,
"markets": [
"HK"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"alphavantage.balance_sheet.retrieve.v1.7aca3c4a": {
"tool_id": "alphavantage.balance_sheet.retrieve.v1.7aca3c4a",
"provider": "alphavantage",
"first_seen_at": "2026-02-22T12:33:41.592Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 6,
"avg_elapsed_ms": 0,
"markets": [
"HK"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"alphavantage.time_series.monthly.retrieve.v1.7aca3c4a": {
"tool_id": "alphavantage.time_series.monthly.retrieve.v1.7aca3c4a",
"provider": "alphavantage",
"first_seen_at": "2026-02-22T12:33:48.563Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 5,
"avg_elapsed_ms": 0,
"markets": [
"HK"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"financial_modeling_prep.discounted_cash_flow.retrieve.v1.0add845f": {
"tool_id": "financial_modeling_prep.discounted_cash_flow.retrieve.v1.0add845f",
"provider": "financial_modeling_prep",
"first_seen_at": "2026-02-22T12:41:29.089Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 5,
"avg_elapsed_ms": 0,
"markets": [
"GLOBAL"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"alphavantage.technical.t3.retrieve.v1.7aca3c4a": {
"tool_id": "alphavantage.technical.t3.retrieve.v1.7aca3c4a",
"provider": "alphavantage",
"first_seen_at": "2026-02-22T12:41:40.880Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 5,
"avg_elapsed_ms": 0,
"markets": [
"GLOBAL"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"financial_modeling_prep.quote_short.retrieve.v1.729a84c2": {
"tool_id": "financial_modeling_prep.quote_short.retrieve.v1.729a84c2",
"provider": "financial_modeling_prep",
"first_seen_at": "2026-02-22T14:15:32.149Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 1,
"avg_elapsed_ms": 0,
"markets": [
"HK"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"twelvedata.splits.retrieve.v1.43527690": {
"tool_id": "twelvedata.splits.retrieve.v1.43527690",
"provider": "twelvedata",
"first_seen_at": "2026-02-22T14:15:48.170Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 1,
"avg_elapsed_ms": 0,
"markets": [
"HK"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"financial_modeling_prep.historical_sector_pe.retrieve.v1.d32f9884": {
"tool_id": "financial_modeling_prep.historical_sector_pe.retrieve.v1.d32f9884",
"provider": "financial_modeling_prep",
"first_seen_at": "2026-02-22T14:36:07.260Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 2,
"avg_elapsed_ms": 0,
"markets": [
"US"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
},
"financial_modeling_prep.custom_levered_discounted_cash_flow.retrieve.v1.b424250d": {
"tool_id": "financial_modeling_prep.custom_levered_discounted_cash_flow.retrieve.v1.b424250d",
"provider": "financial_modeling_prep",
"first_seen_at": "2026-02-22T14:36:08.810Z",
"last_success_at": null,
"success_count": 0,
"fail_count": 2,
"avg_elapsed_ms": 0,
"markets": [
"US"
],
"param_templates": {},
"sample_successful_params": null,
"last_error": null
}
}
}.evolution/run*.json
.cache/*.json
.env
.env.local
../.env.local
cursor/
.cursor/{
"_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" }
}
]
}
{
"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"
}
]
}
{
"updatedAt": "2026-02-22T00:00:00.000Z",
"holdings": [],
"watchlist": []
}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.querycaidazi.report.querycaidazi.search.hybrid.listcaidazi.search.hybrid_v2.query- Global market/news sentiment:
alpha_news_sentimentfinnhub.news- X/Twitter domain sentiment:
qveris_social.x_domain_hot_topicsqveris_social.x_domain_hot_eventsqveris_social.x_domain_new_postsx_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
caidazichannels (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
--evidencefor full parsed/raw trace.
Requirements
- Node.js 18+
QVERIS_API_KEY
export QVERIS_API_KEY="your-api-key"Install as an Independent Skill
npx skills add <repo-url> --skill stock-copilot-proOr 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.mdprovides professional analysis guides for each module- OpenClaw LLM generates concise, actionable final reports
Usage
Analyze one symbol
node scripts/stock_copilot_pro.mjs analyze --symbol AAPL --market US --mode comprehensivePublic-audience preference flow
Without preference flags, the script returns a questionnaire by default.
node scripts/stock_copilot_pro.mjs analyze --symbol AAPLSkip questionnaire and run directly:
node scripts/stock_copilot_pro.mjs analyze --symbol AAPL --skip-questionnaire --style balanced --risk mid --horizon midAnalyze HK/CN symbols
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 comprehensiveCompare multiple symbols
node scripts/stock_copilot_pro.mjs compare --symbols AAPL,MSFT,NVDA --market GLOBAL --mode comprehensiveWatchlist management
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 HKMorning/Evening brief
node scripts/stock_copilot_pro.mjs brief --type morning --format markdown
node scripts/stock_copilot_pro.mjs brief --type evening --format chatIndustry radar
node scripts/stock_copilot_pro.mjs radar --market GLOBAL --limit 10JSON output
node scripts/stock_copilot_pro.mjs analyze --symbol AAPL --format json --skip-questionnaireChat output
node scripts/stock_copilot_pro.mjs analyze --symbol AAPL --format chat --skip-questionnaireEvent radar options
node scripts/stock_copilot_pro.mjs analyze --symbol NVDA --skip-questionnaire --event-view theme --event-window-days 14 --event-universe globalRouting 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 AAPLAnalyze 0700.HK with technical focusCompare AAPL, MSFT, NVDA and rank by risk-rewardAnalyze 600519 with fundamentals and sentimentGive me AAPL analysis with X sentiment includedCompare 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(forwatchcommand)--bucket:holdings|watchlist(forwatch add/remove)--type:morning|evening(forbriefcommand)--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 providerfull_content_file_urlin output (off by default)--evidence: include full parsed/raw evidence sections (off by default)--no-evolution: disable reading/writing.evolution/tool-evolution.jsonfor 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_urlis supported for complete payload retrieval, but runtime restricts fetch host toqveris.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_basicsto补齐公司画像字段。 - For some HK symbols, income/cash-flow fields may still be empty due to upstream coverage; report will explicitly标注数据源空缺。
- Use
--mode basicto 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
fundamentalmode 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_KEYin committed files. .env.localis 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 toqveris.aito prevent arbitrary outbound requests.
Disclaimer
This skill is for research and educational purposes only. It does not constitute financial or investment advice.
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
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)
{
"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
}
}
{
"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"
]
}
}
Tool Selection Strategy
Capability Buckets
quote: real-time quote / OHLCVfundamentals: company overview / key metricstechnicals: RSI / MACD / moving averagessentiment: market news and sentimentx_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, thenAAPL.USHK:0700.HK, then700.HK, then0700CN:600519.SS,600519.SH, then600519GLOBAL: 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.7for primary useavg_execution_time_ms > 0and 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.jsonpriority 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_contentfor summary extraction - Preserve
full_content_file_urlin report notes - Avoid dumping full raw payload into markdown output
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 相对行业",
"趋势强弱与量能",
"重点事件进展",
],
};
}
export const DEFAULT_WATCHLIST = {
updatedAt: null,
holdings: [],
watchlist: [],
};
export const DEFAULT_BRIEF_OPTIONS = {
mode: "basic",
maxItems: 8,
};
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");
}
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);
}
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,
};
}
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",
},
};
}
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}`);
}
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);
}
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 };
}
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");
}
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;
}
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;
}
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");
}
}
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);
}
}
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);
}
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 apiKey = getApiKey();
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 apiKey = getApiKey();
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();
}
}
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 };
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 "⚪";
}
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,
};
}
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"}`;
}
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}`,
};
}