diff --git a/README.md b/README.md index 8f7da91..bd1e6d4 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,11 @@ After connecting, just tell your AI assistant: | | `search_news_advanced` | Multi-filter: coins + keywords + engine types combined | | AI | `get_high_score_news` | High AI impact score articles (0-100 scale) | | | `get_news_by_signal` | By AI trading signal: long / short / neutral | +| Finance | `search_companies` | Search public company candidates by keyword, ticker, CIK, or fuzzy name | +| | `get_company_info` | Resolve a company and list available SEC filings, research reports, transcripts, and financial fields | +| | `get_company_report_text` | Fetch SEC filing, third-party research report, or earnings-call transcript text | +| | `get_crypto_holdings` | Query wallet-visible on-chain holdings evidence for an institution or address | +| | `get_crypto_holding_changes` | Query adjacent-snapshot on-chain holdings changes | | Real-time | `subscribe_latest_news` | WebSocket live feed with coin & engine type filters | > For a comprehensive usage guide with detailed examples, see [Knowledge Guide](./knowledge/guide.md). diff --git a/docs/README_JA.md b/docs/README_JA.md index 4278a5f..3e669c2 100644 --- a/docs/README_JA.md +++ b/docs/README_JA.md @@ -229,6 +229,11 @@ opennews-mcp という MCP サーバーをレビューしてインストール | | `search_news_advanced` | 複合フィルタ:通貨 + キーワード + エンジンタイプ | | AI | `get_high_score_news` | 高 AI 影響度スコア記事(0-100 スケール) | | | `get_news_by_signal` | AI トレーディングシグナル別:long / short / neutral | +| Finance | `search_companies` | キーワード、ticker、CIK、曖昧な会社名で上場企業候補を検索 | +| | `get_company_info` | 会社を解決し、利用可能な SEC filing、リサーチレポート、決算説明会 transcript、財務項目を一覧 | +| | `get_company_report_text` | SEC filing、第三者リサーチ、決算説明会 transcript の本文を取得 | +| | `get_crypto_holdings` | 機関またはアドレスのウォレット可視オンチェーン保有証拠を照会 | +| | `get_crypto_holding_changes` | 隣接スナップショット間のオンチェーン保有変化を照会 | | リアルタイム | `subscribe_latest_news` | WebSocket ライブフィード、通貨・エンジンタイプフィルタ対応 | > 詳細な使用ガイドとサンプルについては、[ナレッジガイド](../knowledge/guide.md) をご覧ください。 diff --git a/docs/README_KO.md b/docs/README_KO.md index 783292d..b1469be 100644 --- a/docs/README_KO.md +++ b/docs/README_KO.md @@ -229,6 +229,11 @@ opennews-mcp MCP 서버를 검토하고 설치해주세요. 프로젝트는 로 | | `search_news_advanced` | 복합 필터: 코인 + 키워드 + 엔진 유형 조합 | | AI | `get_high_score_news` | 높은 AI 영향도 점수 기사 (0-100 스케일) | | | `get_news_by_signal` | AI 트레이딩 시그널별: long / short / neutral | +| 금융 강화 | `search_companies` | 키워드, ticker, CIK 또는 유사 회사명으로 상장사 후보 검색 | +| | `get_company_info` | 회사를 식별하고 사용 가능한 SEC filing, 리서치 보고서, 실적 콜 transcript, 재무 항목을 나열 | +| | `get_company_report_text` | SEC filing, 서드파티 리서치, 실적 콜 transcript 본문 조회 | +| | `get_crypto_holdings` | 기관 또는 주소의 지갑 기준 온체인 보유 증거 조회 | +| | `get_crypto_holding_changes` | 인접 스냅샷 사이의 온체인 보유 변화 조회 | | 실시간 | `subscribe_latest_news` | WebSocket 라이브 피드, 코인 및 엔진 유형 필터 지원 | > 자세한 사용 가이드와 예제는 [지식 가이드](../knowledge/guide.md)를 참조하세요. diff --git a/docs/README_ZH.md b/docs/README_ZH.md index 4ff585a..0aca0a9 100644 --- a/docs/README_ZH.md +++ b/docs/README_ZH.md @@ -229,6 +229,11 @@ cp -r openclaw-skill/opennews ~/.openclaw/skills/ | | `search_news_advanced` | 多条件组合: 币种 + 关键词 + 引擎类型 | | AI | `get_high_score_news` | 高 AI 影响力评分文章 (0-100 分制) | | | `get_news_by_signal` | 按 AI 交易信号: long / short / neutral | +| 金融增强 | `search_companies` | 按关键词、ticker、CIK 或模糊公司名搜索上市公司候选项 | +| | `get_company_info` | 解析公司并列出可用 SEC filings、研报、电话会 transcript 和财务字段 | +| | `get_company_report_text` | 获取 SEC filing、第三方研报或财报电话会 transcript 文本 | +| | `get_crypto_holdings` | 查询机构或地址的钱包可见链上持仓证据 | +| | `get_crypto_holding_changes` | 查询相邻快照之间的链上持仓变化 | | 实时 | `subscribe_latest_news` | WebSocket 实时推送,支持币种和引擎类型过滤 | > 完整使用指南和详细示例,请查看 [知识指南](../knowledge/guide.md)。 diff --git a/knowledge/guide.md b/knowledge/guide.md index 18cb1cc..b62a023 100644 --- a/knowledge/guide.md +++ b/knowledge/guide.md @@ -142,6 +142,15 @@ AI-powered prediction and correlation signals: - **get_high_score_news**: Articles with high AI impact scores (0-100 scale) - **get_news_by_signal**: Filter by AI trading signal — "long" (bullish), "short" (bearish), or "neutral" +### Finance Enhancement +- **search_companies**: Search public company candidates by keyword, ticker, CIK, or fuzzy company name +- **get_company_info**: Resolve a company and list available SEC filings, research reports, earnings-call transcripts, report forms, and financial item names +- **get_company_report_text**: Fetch SEC filing, third-party research report, or earnings-call transcript text by report name, form, accession, source key, quarter hint, section, or text search +- **get_crypto_holdings**: Query wallet-visible on-chain holdings evidence for an institution or address; results are Blockscout address-balance evidence, not proof of beneficial ownership or a trading signal +- **get_crypto_holding_changes**: Query on-chain holdings changes between adjacent snapshots, with optional token, date, and change-type filters + +For the MCP tool, pass `change_types` as a comma-separated string such as `"increase,decrease"`. `get_crypto_holding_changes` defaults `auto_collect` to false; call `get_crypto_holdings` first with `auto_collect=true` or `force_collect=true` when you need to populate a fresh wallet snapshot. + ### Real-time - **subscribe_latest_news**: WebSocket live feed with optional filters by coins, engine types @@ -157,6 +166,8 @@ AI-powered prediction and correlation signals: 8. **Bullish signals**: `get_news_by_signal(signal="long")` — AI-detected bullish catalysts 9. **Multi-filter power search**: `search_news_advanced(coins="BTC,ETH", engine_types="news:Bloomberg,Reuters;market:", keyword="ETF")` — Bloomberg & Reuters ETF news for BTC/ETH plus all market signals 10. **Live monitoring**: `subscribe_latest_news(wait_seconds=15, coins="BTC", engine_types="news:;market:")` — real-time BTC news + market anomalies +11. **Company filings workflow**: `get_company_info(company="IBM")`, then `get_company_report_text(company="IBM", report_name="10-K", form_type="10-K")` — discover and fetch company reports +12. **On-chain holdings evidence**: `get_crypto_holdings(address="0x...", chain="ethereum", token_symbol="ETH")` — inspect visible wallet-balance evidence ## Data Structure diff --git a/openclaw-skill/opennews/SKILL.md b/openclaw-skill/opennews/SKILL.md index a22402e..576b493 100644 --- a/openclaw-skill/opennews/SKILL.md +++ b/openclaw-skill/opennews/SKILL.md @@ -127,6 +127,66 @@ Important: You need to understand the user's query intent and perform word segme --- +## Finance Enhancement Endpoints + +These authenticated endpoints provide company report discovery, company report text, and wallet-visible on-chain holdings evidence. Responses are wrapped as `{"success": true, "data": ...}` and may include usage quota metadata. + +### Company Search + +```bash +curl -s -X POST "https://ai.6551.io/open/finance-enhance/company-search" \ + -H "Authorization: Bearer $OPENNEWS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"keyword": "IBM", "limit": 20, "auto_collect": true}' +``` + +### Company Info + +Returns company identity, available SEC filings, third-party research reports, earnings-call transcripts, report forms, and financial item names. + +```bash +curl -s -X POST "https://ai.6551.io/open/finance-enhance/company-info" \ + -H "Authorization: Bearer $OPENNEWS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"company": "IBM", "auto_collect": true, "filing_limit": 300, "fact_limit": 5000}' +``` + +### Company Report Text + +Fetches SEC filing, third-party research report, or earnings-call transcript text by report name, form, accession, source key, quarter hint, section, or text search. + +```bash +curl -s -X POST "https://ai.6551.io/open/finance-enhance/company-report-text" \ + -H "Authorization: Bearer $OPENNEWS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"company": "IBM", "report_name": "10-K", "form_type": "10-K", "auto_collect": true}' +``` + +### Crypto Holdings Evidence + +Returns Blockscout address-balance evidence for an institution or wallet address. It is not proof of economic ownership, beneficial ownership, investment advice, or a trading signal. + +```bash +curl -s -X POST "https://ai.6551.io/open/finance-enhance/crypto-holdings" \ + -H "Authorization: Bearer $OPENNEWS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"address": "0x...", "chain": "ethereum", "token_symbol": "ETH", "limit": 100}' +``` + +### Crypto Holding Changes + +Returns changes between adjacent wallet snapshots. At least two snapshots are needed to produce changed records. +The raw HTTP API accepts `change_types` as a JSON array. The MCP tool `get_crypto_holding_changes` accepts a comma-separated string such as `increase,decrease` and defaults `auto_collect` to `false`; collect holdings first when a fresh wallet snapshot is needed. + +```bash +curl -s -X POST "https://ai.6551.io/open/finance-enhance/crypto-holding-changes" \ + -H "Authorization: Bearer $OPENNEWS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"address": "0x...", "chain": "ethereum", "change_types": ["increase", "decrease"], "auto_collect": false, "limit": 100}' +``` + +--- + ## Data Structures ### News Article diff --git a/src/opennews_mcp/api_client.py b/src/opennews_mcp/api_client.py index 066345c..22301d1 100644 --- a/src/opennews_mcp/api_client.py +++ b/src/opennews_mcp/api_client.py @@ -160,6 +160,168 @@ class NewsAPIClient: resp = await self._request("POST", f"{self.base_url}/open/news_search", json=body) return resp.json() + async def search_companies( + self, + keyword: str, + limit: int = 20, + auto_collect: bool = True, + ) -> dict: + """POST /open/finance-enhance/company-search — search public company candidates.""" + body = { + "keyword": keyword, + "limit": limit, + "auto_collect": auto_collect, + } + resp = await self._request( + "POST", + f"{self.base_url}/open/finance-enhance/company-search", + json=body, + ) + return resp.json() + + async def get_company_info( + self, + company: str, + auto_collect: bool = True, + filing_limit: int = 300, + fact_limit: int = 5000, + include_all_filings: bool = False, + ) -> dict: + """POST /open/finance-enhance/company-info — get company metadata and report indexes.""" + body = { + "company": company, + "auto_collect": auto_collect, + "filing_limit": filing_limit, + "fact_limit": fact_limit, + "include_all_filings": include_all_filings, + } + resp = await self._request( + "POST", + f"{self.base_url}/open/finance-enhance/company-info", + json=body, + ) + return resp.json() + + async def get_company_report_text( + self, + company: str, + report_name: str, + accession: Optional[str] = None, + form_type: Optional[str] = None, + section_key: Optional[str] = None, + text_search: Optional[str] = None, + auto_collect: bool = True, + max_section_chars: int = 100000, + filing_search_limit: int = 300, + section_limit: int = 1000, + ) -> dict: + """POST /open/finance-enhance/company-report-text — get filing/report/transcript text.""" + body: dict[str, Any] = { + "company": company, + "report_name": report_name, + "auto_collect": auto_collect, + "max_section_chars": max_section_chars, + "filing_search_limit": filing_search_limit, + "section_limit": section_limit, + } + optional_fields = { + "accession": accession, + "form_type": form_type, + "section_key": section_key, + "text_search": text_search, + } + body.update({key: value for key, value in optional_fields.items() if value}) + resp = await self._request( + "POST", + f"{self.base_url}/open/finance-enhance/company-report-text", + json=body, + ) + return resp.json() + + async def get_crypto_holdings( + self, + institution: Optional[str] = None, + address: Optional[str] = None, + chain: Optional[str] = None, + token_symbol: Optional[str] = None, + token_address: Optional[str] = None, + as_of: Optional[str] = None, + auto_collect: bool = True, + force_collect: bool = False, + collect_max_rows: Optional[int] = None, + limit: int = 100, + ) -> dict: + """POST /open/finance-enhance/crypto-holdings — get on-chain holdings evidence.""" + body: dict[str, Any] = { + "auto_collect": auto_collect, + "force_collect": force_collect, + "limit": limit, + } + optional_fields = { + "institution": institution, + "address": address, + "chain": chain, + "token_symbol": token_symbol, + "token_address": token_address, + "as_of": as_of, + "collect_max_rows": collect_max_rows, + } + body.update({ + key: value for key, value in optional_fields.items() + if value is not None and value != "" + }) + resp = await self._request( + "POST", + f"{self.base_url}/open/finance-enhance/crypto-holdings", + json=body, + ) + return resp.json() + + async def get_crypto_holding_changes( + self, + institution: Optional[str] = None, + address: Optional[str] = None, + chain: Optional[str] = None, + token_symbol: Optional[str] = None, + token_address: Optional[str] = None, + change_types: Optional[list[str]] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + include_unchanged: bool = False, + auto_collect: bool = False, + force_collect: bool = False, + collect_max_rows: Optional[int] = None, + limit: int = 100, + ) -> dict: + """POST /open/finance-enhance/crypto-holding-changes — get holdings changes.""" + body: dict[str, Any] = { + "include_unchanged": include_unchanged, + "auto_collect": auto_collect, + "force_collect": force_collect, + "limit": limit, + } + optional_fields = { + "institution": institution, + "address": address, + "chain": chain, + "token_symbol": token_symbol, + "token_address": token_address, + "change_types": change_types, + "start_date": start_date, + "end_date": end_date, + "collect_max_rows": collect_max_rows, + } + body.update({ + key: value for key, value in optional_fields.items() + if value is not None and value != "" and value != [] + }) + resp = await self._request( + "POST", + f"{self.base_url}/open/finance-enhance/crypto-holding-changes", + json=body, + ) + return resp.json() + class NewsWSClient: diff --git a/src/opennews_mcp/tools/__init__.py b/src/opennews_mcp/tools/__init__.py index 200352d..d997191 100644 --- a/src/opennews_mcp/tools/__init__.py +++ b/src/opennews_mcp/tools/__init__.py @@ -4,6 +4,7 @@ Each module is self-contained and atomic — no inter-tool dependencies. """ from opennews_mcp.tools import discovery # noqa: F401 +from opennews_mcp.tools import finance # noqa: F401 from opennews_mcp.tools import free # noqa: F401 from opennews_mcp.tools import news # noqa: F401 from opennews_mcp.tools import realtime # noqa: F401 diff --git a/src/opennews_mcp/tools/finance.py b/src/opennews_mcp/tools/finance.py new file mode 100644 index 0000000..554ac61 --- /dev/null +++ b/src/opennews_mcp/tools/finance.py @@ -0,0 +1,239 @@ +"""Finance enhancement tools — company reports and on-chain holdings evidence.""" + +from mcp.server.fastmcp import Context + +from opennews_mcp.app import mcp +from opennews_mcp.config import make_serializable, require_token + + +def _clamp(value: int, low: int, high: int) -> int: + return min(max(low, value), high) + + +@mcp.tool() +async def search_companies(keyword: str, ctx: Context, limit: int = 20, auto_collect: bool = True) -> dict: + """Search public company candidates by keyword, ticker, CIK, or fuzzy company name. + + Args: + keyword: Search keyword, ticker, CIK, or company name (e.g. "IBM", "Apple"). + limit: Maximum company candidates to return (default 20, max 100). + auto_collect: Whether to trigger backend collection and retry when cache is missing. + """ + if (err := require_token()): + return err + api = ctx.request_context.lifespan_context.api + limit = _clamp(limit, 1, 100) + try: + result = await api.search_companies( + keyword=keyword, + limit=limit, + auto_collect=auto_collect, + ) + return make_serializable(result) + except Exception as e: + return {"success": False, "error": str(e) or repr(e)} + + +@mcp.tool() +async def get_company_info( + company: str, + ctx: Context, + auto_collect: bool = True, + filing_limit: int = 300, + fact_limit: int = 5000, + include_all_filings: bool = False, +) -> dict: + """Resolve a company and return available SEC filings, research reports, transcripts, and financial item indexes. + + Args: + company: Company name, ticker, or CIK (e.g. "IBM", "AAPL", "0000320193"). + auto_collect: Whether to trigger backend collection and retry when cache is missing. + filing_limit: Number of filings to scan (default 300, max 1000). + fact_limit: Number of financial facts to scan (default 5000, max 20000). + include_all_filings: Whether to return all filing forms instead of common report forms only. + """ + if (err := require_token()): + return err + api = ctx.request_context.lifespan_context.api + filing_limit = _clamp(filing_limit, 1, 1000) + fact_limit = _clamp(fact_limit, 1, 20000) + try: + result = await api.get_company_info( + company=company, + auto_collect=auto_collect, + filing_limit=filing_limit, + fact_limit=fact_limit, + include_all_filings=include_all_filings, + ) + return make_serializable(result) + except Exception as e: + return {"success": False, "error": str(e) or repr(e)} + + +@mcp.tool() +async def get_company_report_text( + company: str, + report_name: str, + ctx: Context, + accession: str = "", + form_type: str = "", + section_key: str = "", + text_search: str = "", + auto_collect: bool = True, + max_section_chars: int = 100000, + filing_search_limit: int = 300, + section_limit: int = 1000, +) -> dict: + """Get SEC filing, research report, or earnings-call transcript text by report name or identifier. + + Args: + company: Company name, ticker, or CIK. + report_name: Report name, form, accession, research slug, transcript source key, or quarter hint. + accession: Optional SEC accession, research-report identifier, or transcript identifier. + form_type: Optional SEC form type such as "10-K", "10-Q", or "8-K". + section_key: Optional section key to fetch a specific section. + text_search: Optional keyword to search within report text. + auto_collect: Whether to trigger backend collection and retry when cache is missing. + max_section_chars: Maximum characters per section (default 100000). + filing_search_limit: Candidate filing search limit (default 300). + section_limit: Maximum returned sections (default 1000). + """ + if (err := require_token()): + return err + api = ctx.request_context.lifespan_context.api + max_section_chars = _clamp(max_section_chars, 1, 500000) + filing_search_limit = _clamp(filing_search_limit, 1, 1000) + section_limit = _clamp(section_limit, 1, 5000) + try: + result = await api.get_company_report_text( + company=company, + report_name=report_name, + accession=accession or None, + form_type=form_type or None, + section_key=section_key or None, + text_search=text_search or None, + auto_collect=auto_collect, + max_section_chars=max_section_chars, + filing_search_limit=filing_search_limit, + section_limit=section_limit, + ) + return make_serializable(result) + except Exception as e: + return {"success": False, "error": str(e) or repr(e)} + + +@mcp.tool() +async def get_crypto_holdings( + ctx: Context, + institution: str = "", + address: str = "", + chain: str = "", + token_symbol: str = "", + token_address: str = "", + as_of: str = "", + auto_collect: bool = True, + force_collect: bool = False, + collect_max_rows: int = 0, + limit: int = 100, +) -> dict: + """Query visible on-chain holdings evidence for an institution or wallet address. + + Returns Blockscout address-balance evidence. This is not proof of economic ownership, + beneficial ownership, investment advice, or a trading signal. + + Args: + institution: Optional institution name for watchlist matching or result labeling. + address: Optional wallet address. If address is provided without chain, backend defaults to ethereum. + chain: Optional chain name. + token_symbol: Optional token symbol filter, such as "ETH" or "USDC". + token_address: Optional token contract address filter. + as_of: Optional date or snapshot-time filter. + auto_collect: Whether to collect address balances when cache is missing. + force_collect: Whether to force a fresh address-balance collection. + collect_max_rows: Optional collection row cap; 0 means backend default. + limit: Maximum holdings rows to return (default 100). + """ + if (err := require_token()): + return err + api = ctx.request_context.lifespan_context.api + limit = _clamp(limit, 1, 1000) + collect_rows = _clamp(collect_max_rows, 1, 10000) if collect_max_rows > 0 else None + try: + result = await api.get_crypto_holdings( + institution=institution or None, + address=address or None, + chain=chain or None, + token_symbol=token_symbol or None, + token_address=token_address or None, + as_of=as_of or None, + auto_collect=auto_collect, + force_collect=force_collect, + collect_max_rows=collect_rows, + limit=limit, + ) + return make_serializable(result) + except Exception as e: + return {"success": False, "error": str(e) or repr(e)} + + +@mcp.tool() +async def get_crypto_holding_changes( + ctx: Context, + institution: str = "", + address: str = "", + chain: str = "", + token_symbol: str = "", + token_address: str = "", + change_types: str = "", + start_date: str = "", + end_date: str = "", + include_unchanged: bool = False, + auto_collect: bool = False, + force_collect: bool = False, + collect_max_rows: int = 0, + limit: int = 100, +) -> dict: + """Query on-chain holdings changes between adjacent wallet snapshots. + + Args: + institution: Optional institution name. + address: Optional wallet address. At least two snapshots are needed for changed records. + chain: Optional chain name. + token_symbol: Optional token symbol filter. + token_address: Optional token contract address filter. + change_types: Optional comma-separated filters, such as "increase,decrease". + start_date: Optional start date filter. + end_date: Optional end date filter. + include_unchanged: Whether to include unchanged rows. + auto_collect: Whether to collect address balances when cache is missing. + Defaults to false because address-only change queries can fail in the backend + auto-collection path; collect holdings first when a fresh snapshot is needed. + force_collect: Whether to force a fresh address-balance collection. + collect_max_rows: Optional collection row cap; 0 means backend default. + limit: Maximum change rows to return (default 100). + """ + if (err := require_token()): + return err + api = ctx.request_context.lifespan_context.api + limit = _clamp(limit, 1, 1000) + collect_rows = _clamp(collect_max_rows, 1, 10000) if collect_max_rows > 0 else None + change_type_list = [item.strip() for item in change_types.split(",") if item.strip()] or None + try: + result = await api.get_crypto_holding_changes( + institution=institution or None, + address=address or None, + chain=chain or None, + token_symbol=token_symbol or None, + token_address=token_address or None, + change_types=change_type_list, + start_date=start_date or None, + end_date=end_date or None, + include_unchanged=include_unchanged, + auto_collect=auto_collect, + force_collect=force_collect, + collect_max_rows=collect_rows, + limit=limit, + ) + return make_serializable(result) + except Exception as e: + return {"success": False, "error": str(e) or repr(e)}