diff --git a/README.md b/README.md index 6ba31613..6013f5d0 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ Your one-person Wall Street. Alice is an AI trading agent that gives you your ow ## Features -- **Dual AI provider** — switch between Claude Code CLI and Vercel AI SDK at runtime, no restart needed +- **Multi-provider AI** — switch between Claude Code CLI, Vercel AI SDK, and Agent SDK at runtime, no restart needed - **Unified trading** — multi-account architecture supporting CCXT (Bybit, OKX, Binance, etc.) and Alpaca (US equities) with a git-like workflow (stage, commit, push) - **Guard pipeline** — extensible pre-execution safety checks (max position size, cooldown between trades, symbol whitelist) -- **Market data** — OpenBB-powered equity, crypto, commodity, and currency data layers with unified symbol search (`marketSearchForResearch`) and technical indicator calculator +- **Market data** — TypeScript-native OpenBB engine (`opentypebb`) with no external sidecar required. Covers equity, crypto, commodity, currency, and macro data with unified symbol search (`marketSearchForResearch`) and technical indicator calculator. Can also expose an embedded OpenBB-compatible HTTP API for external tools - **Equity research** — company profiles, financial statements, ratios, analyst estimates, earnings calendar, insider trading, and market movers (top gainers, losers, most active) - **News collector** — background RSS collection from configurable feeds with archive search tools (`globNews`/`grepNews`/`readNews`). Also captures OpenBB news API results via piggyback - **Cognitive state** — persistent "brain" with frontal lobe memory, emotion tracking, and commit history @@ -27,11 +27,11 @@ Your one-person Wall Street. Alice is an AI trading agent that gives you your ow - **Cron scheduling** — event-driven cron system with AI-powered job execution and automatic delivery to the last-interacted channel - **Evolution mode** — two-tier permission system. Normal mode sandboxes the AI to `data/brain/`; evolution mode gives full project access including Bash, enabling the agent to modify its own source code - **Hot-reload** — enable/disable connectors (Telegram, MCP Ask) and reconnect trading engines at runtime without restart -- **Web UI** — local chat interface with portfolio dashboard and full config management (trading, data sources, connectors, settings) +- **Web UI** — local chat interface with real-time SSE streaming, sub-channels with per-channel AI config, portfolio dashboard, and full config management (trading, data sources, connectors, AI provider, heartbeat, tools) ## Key Concepts -**Provider** — The AI backend that powers Alice. Claude Code (subprocess) or Vercel AI SDK (in-process). Switchable at runtime via `ai-provider.json`. +**Provider** — The AI backend that powers Alice. Claude Code (subprocess), Vercel AI SDK (in-process), or Agent SDK (`@anthropic-ai/claude-agent-sdk`). Switchable at runtime via `ai-provider.json`. **Extension** — A self-contained tool package registered in ToolCenter. Each extension owns its tools, state, and persistence. Examples: trading, brain, analysis-kit. @@ -39,7 +39,7 @@ Your one-person Wall Street. Alice is an AI trading agent that gives you your ow **Guard** — A pre-execution check that runs before every trading operation reaches the exchange. Guards enforce limits (max position size, cooldown between trades, symbol whitelist) and can be configured per-asset. -**Connector** — An external interface through which users interact with Alice. Built-in: Web UI, Telegram, MCP Ask. Connectors register with the ConnectorRegistry; delivery always goes to the channel of last interaction. +**Connector** — An external interface through which users interact with Alice. Built-in: Web UI, Telegram, MCP Ask. Connectors register with ConnectorCenter; delivery always goes to the channel of last interaction. **Brain** — Alice's persistent cognitive state. The frontal lobe stores working memory across rounds; emotion tracking logs sentiment shifts with rationale. Both are versioned as commits. @@ -56,16 +56,16 @@ graph LR subgraph Providers CC[Claude Code CLI] VS[Vercel AI SDK] + AS[Agent SDK] end subgraph Core PR[ProviderRouter] AC[AgentCenter] - E[Engine] TC[ToolCenter] S[Session Store] EL[Event Log] - CR[Connector Registry] + CCR[ConnectorCenter] end subgraph Extensions @@ -86,15 +86,14 @@ graph LR subgraph Interfaces WEB[Web UI] TG[Telegram] - HTTP[HTTP API] MCP[MCP Server] end CC --> PR VS --> PR + AS --> PR PR --> AC - AC --> E - E --> S + AC --> S TC -->|Vercel tools| VS TC -->|MCP tools| MCP OBB --> AK @@ -108,23 +107,22 @@ graph LR CRON --> EL HB --> CRON EL --> CRON - CR --> WEB - CR --> TG - WEB --> E - TG --> E - HTTP --> E - MCP --> E + CCR --> WEB + CCR --> TG + WEB --> AC + TG --> AC + MCP --> AC ``` -**Providers** — interchangeable AI backends. Claude Code spawns `claude -p` as a subprocess; Vercel AI SDK runs a `ToolLoopAgent` in-process. `ProviderRouter` reads `ai-provider.json` on each call to select the active backend at runtime. +**Providers** — interchangeable AI backends. Claude Code spawns `claude -p` as a subprocess; Vercel AI SDK runs a `ToolLoopAgent` in-process; Agent SDK uses `@anthropic-ai/claude-agent-sdk`. `ProviderRouter` reads `ai-provider.json` on each call to select the active backend at runtime. -**Core** — `Engine` is a thin facade that delegates to `AgentCenter`, which routes all calls (both stateless and session-aware) through `ProviderRouter`. `ToolCenter` is a centralized tool registry — extensions register tools there, and it exports them in Vercel AI SDK and MCP formats. `EventLog` provides persistent append-only event storage (JSONL) with real-time subscriptions and crash recovery. `ConnectorRegistry` tracks which channel the user last spoke through. +**Core** — `AgentCenter` is the top-level orchestration center that routes all calls (both stateless and session-aware) through `ProviderRouter`. `ToolCenter` is a centralized tool registry — extensions register tools there, and it exports them in Vercel AI SDK and MCP formats. `EventLog` provides persistent append-only event storage (JSONL) with real-time subscriptions and crash recovery. `ConnectorCenter` tracks which channel the user last spoke through. **Extensions** — domain-specific tool sets registered in `ToolCenter`. Each extension owns its tools, state, and persistence. `Guards` enforce pre-execution safety checks (position size limits, trade cooldowns, symbol whitelist) on all trading operations. `NewsCollector` runs background RSS fetches and piggybacks OpenBB news calls into a persistent archive searchable by the agent. -**Tasks** — scheduled background work. `CronEngine` manages jobs and fires `cron.fire` events into the EventLog on schedule; a listener picks them up, runs them through the AI engine, and delivers replies via the ConnectorRegistry. `Heartbeat` is a periodic health-check that uses a structured response protocol (HEARTBEAT_OK / CHAT_NO / CHAT_YES). +**Tasks** — scheduled background work. `CronEngine` manages jobs and fires `cron.fire` events into the EventLog on schedule; a listener picks them up, runs them through `AgentCenter`, and delivers replies via `ConnectorCenter`. `Heartbeat` is a periodic health-check that uses a structured response protocol (HEARTBEAT_OK / CHAT_NO / CHAT_YES). -**Interfaces** — external surfaces. Web UI for local chat, Telegram bot for mobile, HTTP for webhooks, MCP server for tool exposure. External agents can also [converse with Alice via a separate MCP endpoint](docs/mcp-ask-connector.md). +**Interfaces** — external surfaces. Web UI for local chat (with SSE streaming and sub-channels), Telegram bot for mobile, MCP server for tool exposure. External agents can also [converse with Alice via a separate MCP endpoint](docs/mcp-ask-connector.md). ## Quick Start @@ -152,21 +150,25 @@ pnpm test # run tests All config lives in `data/config/` as JSON files with Zod validation. Missing files fall back to sensible defaults. You can edit these files directly or use the Web UI. -**AI Provider** — The default provider is Claude Code (`claude -p` subprocess). To use the [Vercel AI SDK](https://sdk.vercel.ai/docs) instead (Anthropic, OpenAI, Google, etc.), switch `ai-provider.json` to `vercel-ai-sdk` and add your API key to `api-keys.json`. +**AI Provider** — The default provider is Claude Code (`claude -p` subprocess). To use the [Vercel AI SDK](https://sdk.vercel.ai/docs) instead (Anthropic, OpenAI, Google, etc.), switch `ai-provider.json` to `vercel-ai-sdk` and add your API key to `api-keys.json`. A third option, Agent SDK (`@anthropic-ai/claude-agent-sdk`), is also available via `agent-sdk`. **Trading** — Multi-account architecture. Crypto via [CCXT](https://docs.ccxt.com/) (Bybit, OKX, Binance, etc.) configured in `crypto.json`. US equities via [Alpaca](https://alpaca.markets/) configured in `securities.json`. Both use the same git-like trading workflow. | File | Purpose | |------|---------| | `engine.json` | Trading pairs, tick interval, timeframe | -| `model.json` | AI model provider and model name | | `agent.json` | Max agent steps, evolution mode toggle, Claude Code tool permissions | -| `ai-provider.json` | Active AI provider (`vercel-ai-sdk` or `claude-code`), switchable at runtime | +| `ai-provider.json` | Active AI provider (`claude-code`, `vercel-ai-sdk`, or `agent-sdk`), switchable at runtime | | `api-keys.json` | AI provider API keys (Anthropic, OpenAI, Google) — only needed for Vercel AI SDK mode | +| `platforms.json` | Trading platform definitions (CCXT exchanges, Alpaca) | +| `accounts.json` | Trading account credentials and guard config, references platforms | | `crypto.json` | CCXT exchange config + API keys, allowed symbols, guards | | `securities.json` | Alpaca broker config + API keys, allowed symbols, guards | -| `connectors.json` | Web/MCP server ports, Telegram bot credentials + enable, MCP Ask enable | -| `openbb.json` | OpenBB API URL, per-asset-class data providers, provider API keys | +| `connectors.json` | Web/MCP server ports, MCP Ask enable | +| `telegram.json` | Telegram bot credentials + enable | +| `web-subchannels.json` | Web UI sub-channel definitions with per-channel AI provider overrides | +| `tools.json` | Tool enable/disable configuration | +| `openbb.json` | Data backend (`sdk` / `openbb`), per-asset-class providers, provider API keys, embedded HTTP server config | | `news-collector.json` | RSS feeds, fetch interval, retention period, OpenBB piggyback toggle | | `compaction.json` | Context window limits, auto-compaction thresholds | | `heartbeat.json` | Heartbeat enable/disable, interval, active hours | @@ -186,21 +188,25 @@ On first run, defaults are auto-copied to the user override path. Edit the user src/ main.ts # Composition root — wires everything together core/ - engine.ts # Thin facade, delegates to AgentCenter - agent-center.ts # Centralized AI agent management, owns ProviderRouter + agent-center.ts # Top-level AI orchestration center, owns ProviderRouter ai-provider.ts # AIProvider interface + ProviderRouter (runtime switching) tool-center.ts # Centralized tool registry (Vercel + MCP export) ai-config.ts # Runtime provider config read/write + model-factory.ts # Model instance factory for Vercel AI SDK session.ts # JSONL session store + format converters compaction.ts # Auto-summarize long context windows config.ts # Zod-validated config loader event-log.ts # Persistent append-only event log (JSONL) - connector-registry.ts # Last-interacted channel tracker + connector-center.ts # ConnectorCenter — push delivery + last-interacted tracking + async-channel.ts # AsyncChannel for streaming provider events to SSE + provider-utils.ts # Shared provider utilities (session conversion, tool bridging) media.ts # MediaAttachment extraction from tool outputs + media-store.ts # Media file persistence types.ts # Plugin, EngineContext interfaces - providers/ + ai-providers/ claude-code/ # Claude Code CLI subprocess wrapper vercel-ai-sdk/ # Vercel AI SDK ToolLoopAgent wrapper + agent-sdk/ # Agent SDK (@anthropic-ai/claude-agent-sdk) wrapper extension/ analysis-kit/ # Indicator calculator and market data tools equity/ # Equity fundamentals and data adapter @@ -212,21 +218,23 @@ src/ brain/ # Cognitive state (memory, emotion) browser/ # Browser automation bridge (via OpenClaw) openbb/ - equity/ # OpenBB equity data layer (price, fundamentals, estimates, etc.) - crypto/ # OpenBB crypto data layer - currency/ # OpenBB currency data layer - commodity/ # OpenBB commodity data layer (EIA, spot prices) - economy/ # OpenBB macro economy data layer - news/ # OpenBB news data layer + sdk/ # In-process opentypebb SDK clients (equity, crypto, currency, news, economy, commodity) + api-server.ts # Embedded OpenBB-compatible HTTP server (optional, port 6901) + equity/ # Equity data layer + SymbolIndex (SEC/TMX local cache) + crypto/ # Crypto data layer + currency/ # Currency/forex data layer + commodity/ # Commodity data layer (EIA, spot prices) + economy/ # Macro economy data layer + news/ # News data layer + credential-map.ts # Maps config key names to OpenBB credential field names connectors/ - web/ # Web UI chat (Hono, SSE push) + web/ # Web UI chat (Hono, SSE streaming, sub-channels) telegram/ # Telegram bot (grammY, polling, commands) mcp-ask/ # MCP Ask connector (external agent conversation) task/ cron/ # Cron scheduling (engine, listener, AI tools) heartbeat/ # Periodic heartbeat with structured response protocol plugins/ - http.ts # HTTP health/status endpoint mcp.ts # MCP server for tool exposure skills/ # Agent skill definitions openclaw/ # Browser automation subsystem (frozen) diff --git a/package.json b/package.json index 270908b7..d27e93b9 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@ai-sdk/google": "^3.0.30", "@ai-sdk/openai": "^3.0.30", "@alpacahq/alpaca-trade-api": "^3.1.3", + "@anthropic-ai/claude-agent-sdk": "^0.2.72", "@grammyjs/auto-retry": "^2.0.2", "@hono/node-server": "^1.19.11", "@modelcontextprotocol/sdk": "^1.26.0", @@ -46,6 +47,7 @@ "grammy": "^1.40.0", "hono": "^4.12.5", "json5": "^2.2.3", + "opentypebb": "link:./packages/opentypebb", "pino": "^10.3.1", "playwright-core": "1.58.2", "sharp": "^0.34.5", diff --git a/packages/opentypebb/.gitignore b/packages/opentypebb/.gitignore new file mode 100644 index 00000000..62ccde41 --- /dev/null +++ b/packages/opentypebb/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tsbuildinfo +.DS_Store diff --git a/packages/opentypebb/package.json b/packages/opentypebb/package.json new file mode 100644 index 00000000..a9155d93 --- /dev/null +++ b/packages/opentypebb/package.json @@ -0,0 +1,41 @@ +{ + "name": "opentypebb", + "version": "0.1.0", + "description": "TypeScript port of OpenBB Platform — financial data infrastructure", + "type": "module", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + }, + "./server": { + "import": "./src/server.ts", + "types": "./src/server.ts" + } + }, + "scripts": { + "dev": "tsx src/server.ts", + "build": "tsup", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@hono/node-server": "^1.13.8", + "hono": "^4.12.7", + "undici": "^7.22.0", + "yahoo-finance2": "^3.13.1", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/node": "^22.13.4", + "tsup": "^8.4.0", + "tsx": "^4.19.3", + "typescript": "^5.7.3", + "vitest": "^3.0.6" + }, + "engines": { + "node": ">=20.0.0" + }, + "license": "AGPL-3.0" +} diff --git a/packages/opentypebb/pnpm-lock.yaml b/packages/opentypebb/pnpm-lock.yaml new file mode 100644 index 00000000..077640ac --- /dev/null +++ b/packages/opentypebb/pnpm-lock.yaml @@ -0,0 +1,1572 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@hono/node-server': + specifier: ^1.13.8 + version: 1.19.9(hono@4.12.7) + hono: + specifier: ^4.12.7 + version: 4.12.7 + undici: + specifier: ^7.22.0 + version: 7.22.0 + yahoo-finance2: + specifier: ^3.13.1 + version: 3.13.1 + zod: + specifier: ^3.24.2 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^22.13.4 + version: 22.19.13 + tsup: + specifier: ^8.4.0 + version: 8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + tsx: + specifier: ^4.19.3 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.6 + version: 3.2.4(@types/node@22.19.13)(tsx@4.21.0) + +packages: + + '@deno/shim-deno-test@0.5.0': + resolution: {integrity: sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==} + + '@deno/shim-deno@0.18.2': + resolution: {integrity: sha512-oQ0CVmOio63wlhwQF75zA4ioolPvOwAoK0yuzcS5bDC1JUvH3y1GS8xPh8EOpcoDQRU4FTG8OQfxhpR+c6DrzA==} + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@22.19.13': + resolution: {integrity: sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==} + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-mock-cache@2.3.1: + resolution: {integrity: sha512-hDk+Nbt0Y8Aq7KTEU6ASQAcpB34UjhkpD3QjzD6yvEKP4xVElAqXrjQ7maL+LYMGafx51Zq6qUfDM57PNu/qMw==} + + filename-reserved-regex@2.0.0: + resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} + engines: {node: '>=4'} + + filenamify-url@2.1.2: + resolution: {integrity: sha512-3rMbAr7vDNMOGsj1aMniQFl749QjgM+lMJ/77ZRSPTIgxvolZwoQbn8dXLs7xfd+hAdli+oTnSWZNkJJLWQFEQ==} + engines: {node: '>=8'} + + filenamify@4.3.0: + resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==} + engines: {node: '>=8'} + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + hono@4.12.7: + resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} + engines: {node: '>=16.9.0'} + + humanize-url@2.1.1: + resolution: {integrity: sha512-V4nxsPGNE7mPjr1qDp471YfW8nhBiTRWrG/4usZlpvFU8I7gsV7Jvrrzv/snbLm5dWO3dr1ennu2YqnhTWFmYA==} + engines: {node: '>=8'} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + normalize-url@4.5.1: + resolution: {integrity: sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==} + engines: {node: '>=8'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + strip-outer@1.0.1: + resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} + engines: {node: '>=0.10.0'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie-file-store@2.0.3: + resolution: {integrity: sha512-sMpZVcmFf6EYFHFFl+SYH4W1/OnXBYMGDsv2IlbQ2caHyFElW/UR/gpj/KYU1JwmP4dE9xqwv2+vWcmlXHojSw==} + engines: {node: '>=6'} + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + trim-repeated@1.0.0: + resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==} + engines: {node: '>=0.10.0'} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} + engines: {node: '>=20.18.1'} + + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yahoo-finance2@3.13.1: + resolution: {integrity: sha512-kn3unY2OflG1NbeONedWxFDzq5QDyMYYAnr6VjRIOsMv5Q7ZXZZYFM8OadNZrOev4ikQjbYcMLLjogpVaez/vQ==} + engines: {node: '>=20.0.0'} + hasBin: true + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@deno/shim-deno-test@0.5.0': {} + + '@deno/shim-deno@0.18.2': + dependencies: + '@deno/shim-deno-test': 0.5.0 + which: 4.0.0 + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@hono/node-server@1.19.9(hono@4.12.7)': + dependencies: + hono: 4.12.7 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/node@22.19.13': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.13)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.13)(tsx@4.21.0) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + acorn@8.16.0: {} + + any-promise@1.3.0: {} + + assertion-error@2.0.1: {} + + bundle-require@5.1.0(esbuild@0.27.3): + dependencies: + esbuild: 0.27.3 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escape-string-regexp@1.0.5: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fetch-mock-cache@2.3.1: + dependencies: + debug: 4.4.3 + filenamify-url: 2.1.2 + transitivePeerDependencies: + - supports-color + + filename-reserved-regex@2.0.0: {} + + filenamify-url@2.1.2: + dependencies: + filenamify: 4.3.0 + humanize-url: 2.1.1 + + filenamify@4.3.0: + dependencies: + filename-reserved-regex: 2.0.0 + strip-outer: 1.0.1 + trim-repeated: 1.0.0 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.0 + rollup: 4.59.0 + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + hono@4.12.7: {} + + humanize-url@2.1.1: + dependencies: + normalize-url: 4.5.1 + + isexe@3.1.5: {} + + joycon@3.1.1: {} + + js-tokens@9.0.1: {} + + json-schema@0.4.0: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mlly@1.8.0: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + normalize-url@4.5.1: {} + + object-assign@4.1.1: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.6)(tsx@4.21.0): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.6 + tsx: 4.21.0 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + punycode@2.3.1: {} + + querystringify@2.2.0: {} + + readdirp@4.1.2: {} + + requires-port@1.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + strip-outer@1.0.1: + dependencies: + escape-string-regexp: 1.0.5 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.15 + ts-interface-checker: 0.1.13 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie-file-store@2.0.3: + dependencies: + tough-cookie: 4.1.4 + + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tree-kill@1.2.2: {} + + trim-repeated@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.3) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.3 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.6)(tsx@4.21.0) + resolve-from: 5.0.0 + rollup: 4.59.0 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.6 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tsx@4.21.0: + dependencies: + esbuild: 0.27.3 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + ufo@1.6.3: {} + + undici-types@6.21.0: {} + + undici@7.22.0: {} + + universalify@0.2.0: {} + + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + + vite-node@3.2.4(@types/node@22.19.13)(tsx@4.21.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@22.19.13)(tsx@4.21.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.1(@types/node@22.19.13)(tsx@4.21.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.13 + fsevents: 2.3.3 + tsx: 4.21.0 + + vitest@3.2.4(@types/node@22.19.13)(tsx@4.21.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.13)(tsx@4.21.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@22.19.13)(tsx@4.21.0) + vite-node: 3.2.4(@types/node@22.19.13)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.13 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + which@4.0.0: + dependencies: + isexe: 3.1.5 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yahoo-finance2@3.13.1: + dependencies: + '@deno/shim-deno': 0.18.2 + fetch-mock-cache: 2.3.1 + json-schema: 0.4.0 + tough-cookie: 5.1.2 + tough-cookie-file-store: 2.0.3 + transitivePeerDependencies: + - supports-color + + zod@3.25.76: {} diff --git a/packages/opentypebb/src/core/api/app-loader.ts b/packages/opentypebb/src/core/api/app-loader.ts new file mode 100644 index 00000000..6bcd3b4e --- /dev/null +++ b/packages/opentypebb/src/core/api/app-loader.ts @@ -0,0 +1,88 @@ +/** + * App Loader — load providers and mount extension routers. + * Maps to: openbb_core/api/app_loader.py + * + * In Python, RegistryLoader uses entry_points for dynamic discovery. + * In TypeScript, providers and routers are explicitly imported + * (simpler, tree-shake friendly, easier to debug). + */ + +import { Registry } from '../provider/registry.js' +import { QueryExecutor } from '../provider/query-executor.js' +import { Router } from '../app/router.js' + +// --- Providers (explicit imports replace entry_points) --- +import { fmpProvider } from '../../providers/fmp/index.js' +import { yfinanceProvider } from '../../providers/yfinance/index.js' +import { deribitProvider } from '../../providers/deribit/index.js' +import { cboeProvider } from '../../providers/cboe/index.js' +import { multplProvider } from '../../providers/multpl/index.js' +import { oecdProvider } from '../../providers/oecd/index.js' +import { econdbProvider } from '../../providers/econdb/index.js' +import { imfProvider } from '../../providers/imf/index.js' +import { ecbProvider } from '../../providers/ecb/index.js' +import { federalReserveProvider } from '../../providers/federal_reserve/index.js' +import { intrinioProvider } from '../../providers/intrinio/index.js' +import { blsProvider } from '../../providers/bls/index.js' +import { eiaProvider } from '../../providers/eia/index.js' +import { stubProvider } from '../../providers/stub/index.js' + +// --- Extension routers --- +import { equityRouter } from '../../extensions/equity/equity-router.js' +import { cryptoRouter } from '../../extensions/crypto/crypto-router.js' +import { currencyRouter } from '../../extensions/currency/currency-router.js' +import { newsRouter } from '../../extensions/news/news-router.js' +import { economyRouter } from '../../extensions/economy/economy-router.js' +import { etfRouter } from '../../extensions/etf/etf-router.js' +import { indexRouter } from '../../extensions/index/index-router.js' +import { derivativesRouter } from '../../extensions/derivatives/derivatives-router.js' +import { commodityRouter } from '../../extensions/commodity/commodity-router.js' + +/** + * Create and populate a Registry with all available providers. + * Maps to: RegistryLoader.from_extensions() in registry_loader.py + */ +export function createRegistry(): Registry { + const registry = new Registry() + registry.includeProvider(fmpProvider) + registry.includeProvider(yfinanceProvider) + registry.includeProvider(deribitProvider) + registry.includeProvider(cboeProvider) + registry.includeProvider(multplProvider) + registry.includeProvider(oecdProvider) + registry.includeProvider(econdbProvider) + registry.includeProvider(imfProvider) + registry.includeProvider(ecbProvider) + registry.includeProvider(federalReserveProvider) + registry.includeProvider(intrinioProvider) + registry.includeProvider(blsProvider) + registry.includeProvider(eiaProvider) + registry.includeProvider(stubProvider) + return registry +} + +/** + * Create a QueryExecutor with all providers loaded. + */ +export function createExecutor(): QueryExecutor { + const registry = createRegistry() + return new QueryExecutor(registry) +} + +/** + * Load all extension routers and return a root router. + * Maps to: RouterLoader in app_loader.py + */ +export function loadAllRouters(): Router { + const root = new Router({ description: 'OpenTypeBB API' }) + root.includeRouter(equityRouter) + root.includeRouter(cryptoRouter) + root.includeRouter(currencyRouter) + root.includeRouter(newsRouter) + root.includeRouter(economyRouter) + root.includeRouter(etfRouter) + root.includeRouter(indexRouter) + root.includeRouter(derivativesRouter) + root.includeRouter(commodityRouter) + return root +} diff --git a/packages/opentypebb/src/core/api/rest-api.ts b/packages/opentypebb/src/core/api/rest-api.ts new file mode 100644 index 00000000..c5628562 --- /dev/null +++ b/packages/opentypebb/src/core/api/rest-api.ts @@ -0,0 +1,45 @@ +/** + * REST API setup using Hono. + * Maps to: openbb_core/api/rest_api.py + * + * Creates the Hono app with: + * - CORS middleware + * - Default credential injection middleware + * - Error handling + * - Health check endpoint + */ + +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { serve } from '@hono/node-server' +import type { Credentials } from '../app/model/credentials.js' + +/** + * Create the Hono app with middleware configured. + * Maps to: the FastAPI app creation in rest_api.py + * + * @param defaultCredentials - Default credentials injected into every request + * (can be overridden per-request via X-OpenBB-Credentials header) + */ +export function createApp( + defaultCredentials: Credentials = {}, +): Hono { + const app = new Hono() + + // CORS middleware (allow all origins by default, matching OpenBB defaults) + app.use(cors()) + + // Health check + app.get('/api/v1/health', (c) => c.json({ status: 'ok' })) + + return app +} + +/** + * Start the HTTP server. + * Maps to: uvicorn.run() in rest_api.py + */ +export function startServer(app: Hono, port = 6900): void { + serve({ fetch: app.fetch, port }) + console.log(`OpenTypeBB listening on http://localhost:${port}`) +} diff --git a/packages/opentypebb/src/core/app/command-runner.ts b/packages/opentypebb/src/core/app/command-runner.ts new file mode 100644 index 00000000..6bbb5fba --- /dev/null +++ b/packages/opentypebb/src/core/app/command-runner.ts @@ -0,0 +1,30 @@ +/** + * Command Runner. + * Maps to: openbb_core/app/command_runner.py + * + * In Python, CommandRunner orchestrates: + * 1. ParametersBuilder.build() — validate & coerce params + * 2. Execute the command function + * 3. Attach metadata (duration, route, timestamp) + * 4. Trigger on_command_output callbacks + * + * In TypeScript, this is simplified since we don't have FastAPI's + * dependency injection system. The command is just a function that + * creates a Query and executes it. + */ + +import type { QueryExecutor } from '../provider/query-executor.js' +import { Query, type QueryConfig } from './query.js' +import type { OBBject } from './model/obbject.js' + +export class CommandRunner { + constructor(private readonly executor: QueryExecutor) {} + + /** + * Run a command by creating and executing a Query. + */ + async run(config: QueryConfig): Promise> { + const query = new Query(this.executor, config) + return query.execute() + } +} diff --git a/packages/opentypebb/src/core/app/model/credentials.ts b/packages/opentypebb/src/core/app/model/credentials.ts new file mode 100644 index 00000000..eba1712d --- /dev/null +++ b/packages/opentypebb/src/core/app/model/credentials.ts @@ -0,0 +1,32 @@ +/** + * Credentials management. + * Maps to: openbb_core/app/model/credentials.py + * + * In Python, Credentials is dynamically generated from all provider requirements. + * In TypeScript, we use a simple Record since we don't need + * Pydantic's SecretStr obfuscation — credentials are plain strings passed through. + */ + +export type Credentials = Record + +/** + * Build a credentials record from provider key mappings. + * Similar to how open-alice's credential-map.ts works. + * + * @param providerKeys - Map of short key names to API key values. + * @param keyMapping - Map of short names to full credential names. + * @returns Full credentials record. + */ +export function buildCredentials( + providerKeys: Record, + keyMapping: Record, +): Credentials { + const credentials: Credentials = {} + for (const [shortName, fullName] of Object.entries(keyMapping)) { + const value = providerKeys[shortName] + if (value) { + credentials[fullName] = value + } + } + return credentials +} diff --git a/packages/opentypebb/src/core/app/model/metadata.ts b/packages/opentypebb/src/core/app/model/metadata.ts new file mode 100644 index 00000000..46685f8c --- /dev/null +++ b/packages/opentypebb/src/core/app/model/metadata.ts @@ -0,0 +1,31 @@ +/** + * Request metadata for tracking query execution. + * Maps to metadata attached in command_runner.py's _execute_func. + */ + +export interface RequestMetadata { + /** Route path (e.g., "/equity/price/historical"). */ + route: string + /** Query arguments. */ + arguments: Record + /** Execution duration in milliseconds. */ + duration: number + /** Timestamp of execution. */ + timestamp: string +} + +/** + * Create request metadata for a query execution. + */ +export function createMetadata( + route: string, + args: Record, + startTime: number, +): RequestMetadata { + return { + route, + arguments: args, + duration: Date.now() - startTime, + timestamp: new Date().toISOString(), + } +} diff --git a/packages/opentypebb/src/core/app/model/obbject.ts b/packages/opentypebb/src/core/app/model/obbject.ts new file mode 100644 index 00000000..70ee76f0 --- /dev/null +++ b/packages/opentypebb/src/core/app/model/obbject.ts @@ -0,0 +1,102 @@ +/** + * OBBject — the universal response envelope. + * Maps to: openbb_core/app/model/obbject.py + * + * In Python, OBBject is a Generic[T] Pydantic model with: + * results: T | None + * provider: str | None + * warnings: List[Warning_] | None + * chart: Chart | None + * extra: Dict[str, Any] + * + * It also has from_query() classmethod, to_dataframe(), to_dict(), etc. + * In TypeScript, we skip the DataFrame/Polars/NumPy conversion methods. + */ + +export interface Warning { + category: string + message: string +} + +export interface OBBjectData { + results: T[] | null + provider: string | null + warnings: Warning[] | null + chart: unknown | null + extra: Record +} + +export class OBBject { + results: T[] | null + provider: string | null + warnings: Warning[] | null + chart: unknown | null + extra: Record + + // Private metadata (matches Python's PrivateAttr fields) + private _route: string | null = null + private _standardParams: Record | null = null + private _extraParams: Record | null = null + + constructor(data: Partial> = {}) { + this.results = data.results ?? null + this.provider = data.provider ?? null + this.warnings = data.warnings ?? null + this.chart = data.chart ?? null + this.extra = data.extra ?? {} + } + + /** Set route metadata. */ + setRoute(route: string): this { + this._route = route + return this + } + + /** Set standard params metadata. */ + setStandardParams(params: Record): this { + this._standardParams = params + return this + } + + /** Set extra params metadata. */ + setExtraParams(params: Record): this { + this._extraParams = params + return this + } + + /** Get route metadata. */ + get route(): string | null { + return this._route + } + + /** JSON-serializable representation for HTTP responses. */ + toJSON(): OBBjectData { + return { + results: this.results, + provider: this.provider, + warnings: this.warnings, + chart: this.chart, + extra: this.extra, + } + } + + /** + * Create OBBject from query execution result. + * Maps to: OBBject.from_query() in obbject.py + * + * In the simplified TypeScript version, this directly wraps + * the fetcher result rather than going through the full + * Query → ProviderInterface → CommandRunner pipeline. + */ + static fromResults( + results: R[], + provider: string, + extra?: Record, + ): OBBject { + return new OBBject({ + results, + provider, + extra, + }) + } +} diff --git a/packages/opentypebb/src/core/app/query.ts b/packages/opentypebb/src/core/app/query.ts new file mode 100644 index 00000000..aca24018 --- /dev/null +++ b/packages/opentypebb/src/core/app/query.ts @@ -0,0 +1,76 @@ +/** + * Query class. + * Maps to: openbb_core/app/query.py + * + * In Python, Query holds CommandContext + ProviderChoices + StandardParams + ExtraParams, + * and execute() calls ProviderInterface → QueryExecutor. + * + * In TypeScript, Query is simplified: + * - Takes provider name, model name, params, and credentials directly + * - Delegates to QueryExecutor.execute() + * - No ProviderInterface dependency injection (handled by Router) + */ + +import type { QueryExecutor } from '../provider/query-executor.js' +import { OBBject } from './model/obbject.js' +import { createMetadata } from './model/metadata.js' + +export interface QueryConfig { + /** Provider name (e.g., "fmp"). */ + provider: string + /** Model name (e.g., "EquityHistorical"). */ + model: string + /** Merged params (standard + extra). */ + params: Record + /** Provider credentials. */ + credentials: Record | null + /** Route path for metadata. */ + route?: string +} + +export class Query { + readonly provider: string + readonly model: string + readonly params: Record + readonly credentials: Record | null + readonly route: string + + constructor( + private readonly executor: QueryExecutor, + config: QueryConfig, + ) { + this.provider = config.provider + this.model = config.model + this.params = config.params + this.credentials = config.credentials + this.route = config.route ?? `/${config.model}` + } + + /** + * Execute the query and return an OBBject. + * Maps to: Query.execute() in query.py + OBBject.from_query() + */ + async execute(): Promise> { + const startTime = Date.now() + + const results = await this.executor.execute( + this.provider, + this.model, + this.params, + this.credentials, + ) + + const metadata = createMetadata(this.route, this.params, startTime) + + const obbject = new OBBject({ + results: results as T[], + provider: this.provider, + extra: { metadata }, + }) + + obbject.setRoute(this.route) + obbject.setStandardParams(this.params) + + return obbject + } +} diff --git a/packages/opentypebb/src/core/app/router.ts b/packages/opentypebb/src/core/app/router.ts new file mode 100644 index 00000000..d0c250ce --- /dev/null +++ b/packages/opentypebb/src/core/app/router.ts @@ -0,0 +1,218 @@ +/** + * Router — command registration and routing. + * Maps to: openbb_core/app/router.py + * + * In Python, Router wraps FastAPI's APIRouter with: + * - @router.command(model="...") decorator for registering commands + * - include_router() for hierarchical nesting + * - Auto-generates FastAPI routes with dependency injection + * + * In TypeScript, Router serves two purposes: + * 1. Library mode: getCommandMap() returns a flat map of commands + * 2. HTTP mode: mountToHono() generates Hono routes + * + * Each command is a thin function that delegates to Query.execute(). + */ + +import type { Hono } from 'hono' +import type { QueryExecutor } from '../provider/query-executor.js' + +/** + * Coerce a URL query-string value to an appropriate JS type. + * URL params are always strings, but Zod schemas (like OpenBB's Pydantic models) + * expect native numbers/booleans. FastAPI does this automatically; we replicate it here. + */ +function coerceQueryValue(value: string): unknown { + // Boolean + if (value === 'true') return true + if (value === 'false') return false + // Null + if (value === 'null' || value === 'none' || value === 'None') return null + // Number (integer or float) — but NOT date-like strings like "2024-01-01" + if (/^-?\d+$/.test(value)) return Number(value) + if (/^-?\d+\.\d+$/.test(value)) return Number(value) + // Keep as string + return value +} + +/** + * A registered command handler. + * The handler receives params + credentials and returns the raw result. + */ +export interface CommandHandler { + ( + executor: QueryExecutor, + provider: string, + params: Record, + credentials: Record | null, + ): Promise +} + +/** Command definition registered in a Router. */ +export interface CommandDef { + /** Standard model name (e.g., "EquityHistorical"). */ + model: string + /** Route path segment (e.g., "/historical"). */ + path: string + /** Human-readable description. */ + description: string + /** The handler function. */ + handler: CommandHandler +} + +/** + * Router class for registering commands and building routes. + * + * Usage in extensions (maps to Python's @router.command pattern): + * + * ```typescript + * const router = new Router({ prefix: '/price' }) + * + * router.command({ + * model: 'EquityQuote', + * path: '/quote', + * description: 'Get the latest quote for a given stock.', + * handler: async (executor, provider, params, credentials) => { + * return executor.execute(provider, 'EquityQuote', params, credentials) + * }, + * }) + * ``` + */ +export class Router { + readonly prefix: string + readonly description?: string + private readonly _commands: CommandDef[] = [] + private readonly _subRouters: Array<{ prefix: string; router: Router }> = [] + + constructor(opts: { prefix?: string; description?: string } = {}) { + this.prefix = opts.prefix ?? '' + this.description = opts.description + } + + /** + * Register a command. + * Maps to: @router.command(model="...", ...) in router.py + */ + command(def: CommandDef): void { + this._commands.push(def) + } + + /** + * Include a sub-router. + * Maps to: router.include_router(sub_router, prefix="/price") in router.py + */ + includeRouter(router: Router, prefix?: string): void { + this._subRouters.push({ + prefix: prefix ?? router.prefix, + router, + }) + } + + /** + * Get all commands as a flat map of {fullPath: CommandDef}. + * Used in library mode for direct invocation. + */ + getCommandMap(basePath = ''): Map { + const map = new Map() + const fullPrefix = basePath + this.prefix + + for (const cmd of this._commands) { + map.set(fullPrefix + cmd.path, cmd) + } + + for (const { router } of this._subRouters) { + // Let the sub-router add its own prefix — don't add the stored prefix too + // (stored prefix defaults to router.prefix, so it would be applied twice) + const subMap = router.getCommandMap(fullPrefix) + for (const [path, cmd] of subMap) { + map.set(path, cmd) + } + } + + return map + } + + /** + * Get all registered model names. + * Useful for discovering available commands. + */ + getModelNames(basePath = ''): string[] { + const names: string[] = [] + const fullPrefix = basePath + this.prefix + + for (const cmd of this._commands) { + names.push(cmd.model) + } + + for (const { router } of this._subRouters) { + names.push(...router.getModelNames(fullPrefix)) + } + + return names + } + + /** + * Mount all commands as Hono GET routes. + * Maps to: AppLoader.add_routers() / RouterLoader in rest_api.py + * + * Each command becomes: GET /api/v1/{extension}/{path}?params... + * - Provider is taken from ?provider= query param + * - Credentials from X-OpenBB-Credentials header + */ + mountToHono( + app: Hono, + executor: QueryExecutor, + basePath = '/api/v1', + ): void { + const commands = this.getCommandMap(basePath) + + for (const [path, cmd] of commands) { + app.get(path, async (c) => { + const url = new URL(c.req.url) + const params: Record = {} + for (const [key, value] of url.searchParams) { + // Coerce URL query param strings to appropriate JS types. + // FastAPI does this automatically via type annotations; we replicate it here. + params[key] = coerceQueryValue(value) + } + + // Extract provider from query params (matches OpenBB behavior) + const provider = (params.provider as string) ?? '' + delete params.provider + + // Parse credentials from header + const credHeader = c.req.header('X-OpenBB-Credentials') + let credentials: Record | null = null + if (credHeader) { + try { + credentials = JSON.parse(credHeader) + } catch { + // Ignore malformed credential header + } + } + + try { + const result = await cmd.handler(executor, provider, params, credentials) + // Wrap in OBBject-compatible envelope (matches OpenBB Python response format) + return c.json({ + results: Array.isArray(result) ? result : [result], + provider, + warnings: null, + chart: null, + extra: {}, + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return c.json({ + results: null, + provider, + warnings: null, + chart: null, + extra: {}, + error: message, + }, 500) + } + }) + } + } +} diff --git a/packages/opentypebb/src/core/provider/abstract/data.ts b/packages/opentypebb/src/core/provider/abstract/data.ts new file mode 100644 index 00000000..ac4639e0 --- /dev/null +++ b/packages/opentypebb/src/core/provider/abstract/data.ts @@ -0,0 +1,37 @@ +/** + * Base Data schema. + * Maps to: openbb_core/provider/abstract/data.py + * + * In Python, Data is a Pydantic BaseModel with: + * - __alias_dict__: maps {newFieldName: originalFieldName} for input aliasing + * - AliasGenerator with camelCase validation / snake_case serialization + * - ConfigDict(extra="allow", populate_by_name=True, strict=False) + * - model_validator(mode="before") that applies __alias_dict__ + * + * In TypeScript, we use Zod schemas. The alias handling is done explicitly + * in each Fetcher's transformData via the applyAliases() helper. + */ + +import { z } from 'zod' + +/** + * Base Data schema — empty by default. + * Standard models extend this with their specific fields. + * Provider-specific models further extend the standard. + * + * Using .passthrough() to match Python's ConfigDict(extra="allow"). + */ +export const BaseDataSchema = z.object({}).passthrough() + +export type BaseData = z.infer + +/** + * ForceInt — coerces a value to integer. + * Maps to: ForceInt = Annotated[int, BeforeValidator(check_int)] in data.py + */ +export const ForceInt = z.preprocess((v) => { + if (v === null || v === undefined) return v + const n = Number(v) + if (isNaN(n)) return v + return Math.trunc(n) +}, z.number().int().nullable()) diff --git a/packages/opentypebb/src/core/provider/abstract/fetcher.ts b/packages/opentypebb/src/core/provider/abstract/fetcher.ts new file mode 100644 index 00000000..049fcc95 --- /dev/null +++ b/packages/opentypebb/src/core/provider/abstract/fetcher.ts @@ -0,0 +1,102 @@ +/** + * Abstract Fetcher class — the TET (Transform, Extract, Transform) pipeline. + * Maps to: openbb_core/provider/abstract/fetcher.py + * + * In Python, Fetcher is Generic[Q, R] with three static methods: + * 1. transform_query(params: dict) -> Q — validate & coerce input params + * 2. extract_data(query: Q, creds) -> Any — fetch raw data from provider API + * 3. transform_data(query: Q, data: Any) -> R — parse raw data into typed output + * 4. fetch_data() orchestrates the above pipeline + * + * Subclasses implement either extract_data (sync) or aextract_data (async). + * In TypeScript, we only need async (extractData is always async). + * + * Fetcher classes are never instantiated — all methods are static. + * This matches the Python pattern where all methods are @staticmethod. + */ + +/** Type for a Fetcher class (not instance). */ +export interface FetcherClass { + /** Whether this fetcher requires provider credentials. */ + requireCredentials: boolean + + /** Transform raw params dict into typed query object. */ + transformQuery(params: Record): unknown + + /** Extract raw data from the provider API. */ + extractData( + query: unknown, + credentials: Record | null, + ): Promise + + /** Transform raw data into typed result. */ + transformData(query: unknown, data: unknown): unknown + + /** Full pipeline: transformQuery → extractData → transformData */ + fetchData( + params: Record, + credentials?: Record | null, + ): Promise +} + +/** + * Abstract Fetcher base class. + * + * Each provider model creates a concrete subclass: + * + * ```typescript + * export class FMPEquityProfileFetcher extends Fetcher { + * static requireCredentials = true + * + * static transformQuery(params) { + * return FMPEquityProfileQueryParamsSchema.parse(params) + * } + * + * static async extractData(query, credentials) { + * const apiKey = credentials?.fmp_api_key ?? '' + * return await amakeRequest(`https://...?apikey=${apiKey}`) + * } + * + * static transformData(query, data) { + * return data.map(d => FMPEquityProfileDataSchema.parse(applyAliases(d, aliasDict))) + * } + * } + * ``` + */ +export abstract class Fetcher { + /** Whether this fetcher requires provider credentials. Can be overridden by subclasses. */ + static requireCredentials = true + + /** Transform the params to the provider-specific query. */ + static transformQuery(_params: Record): unknown { + throw new Error('transformQuery not implemented') + } + + /** Extract the data from the provider (async). */ + static async extractData( + _query: unknown, + _credentials: Record | null, + ): Promise { + throw new Error('extractData not implemented') + } + + /** Transform the provider-specific data. */ + static transformData(_query: unknown, _data: unknown): unknown { + throw new Error('transformData not implemented') + } + + /** + * Fetch data from a provider. + * Orchestrates the TET pipeline: transformQuery → extractData → transformData. + * + * Maps to: Fetcher.fetch_data() in fetcher.py + */ + static async fetchData( + params: Record, + credentials: Record | null = null, + ): Promise { + const query = this.transformQuery(params) + const data = await this.extractData(query, credentials) + return this.transformData(query, data) + } +} diff --git a/packages/opentypebb/src/core/provider/abstract/provider.ts b/packages/opentypebb/src/core/provider/abstract/provider.ts new file mode 100644 index 00000000..16412fa5 --- /dev/null +++ b/packages/opentypebb/src/core/provider/abstract/provider.ts @@ -0,0 +1,64 @@ +/** + * Provider class. + * Maps to: openbb_core/provider/abstract/provider.py + * + * Serves as the provider extension entry point. Each data provider + * (yfinance, fmp, sec, etc.) creates a Provider instance with its + * name, description, credentials, and a fetcher_dict mapping model + * names to Fetcher classes. + */ + +import type { FetcherClass } from './fetcher.js' + +export interface ProviderConfig { + /** Short name of the provider (e.g., "fmp", "yfinance"). */ + name: string + /** Description of the provider. */ + description: string + /** Website URL of the provider. */ + website?: string + /** + * List of required credential names (without provider prefix). + * Will be auto-prefixed with the provider name. + * Example: ["api_key"] → ["fmp_api_key"] + */ + credentials?: string[] + /** + * Dictionary mapping model names to Fetcher classes. + * Example: { "EquityHistorical": FMPEquityHistoricalFetcher } + */ + fetcherDict: Record + /** Full display name of the provider. */ + reprName?: string + /** Instructions on how to set up the provider (e.g., how to get an API key). */ + instructions?: string +} + +export class Provider { + readonly name: string + readonly description: string + readonly website?: string + readonly credentials: string[] + readonly fetcherDict: Record + readonly reprName?: string + readonly instructions?: string + + constructor(config: ProviderConfig) { + this.name = config.name + this.description = config.description + this.website = config.website + this.fetcherDict = config.fetcherDict + this.reprName = config.reprName + this.instructions = config.instructions + + // Auto-prefix credentials with provider name (matches Python behavior) + // Example: credentials=["api_key"], name="fmp" → ["fmp_api_key"] + if (config.credentials) { + this.credentials = config.credentials.map( + (c) => `${this.name.toLowerCase()}_${c}`, + ) + } else { + this.credentials = [] + } + } +} diff --git a/packages/opentypebb/src/core/provider/abstract/query-params.ts b/packages/opentypebb/src/core/provider/abstract/query-params.ts new file mode 100644 index 00000000..b4865ca1 --- /dev/null +++ b/packages/opentypebb/src/core/provider/abstract/query-params.ts @@ -0,0 +1,25 @@ +/** + * Base QueryParams schema. + * Maps to: openbb_core/provider/abstract/query_params.py + * + * In Python, QueryParams is a Pydantic BaseModel with: + * - __alias_dict__: maps field names to API aliases for model_dump() + * - __json_schema_extra__: provider-specific schema hints + * - ConfigDict(extra="allow", populate_by_name=True) + * + * In TypeScript, we use Zod schemas. Extensions use .extend() to add fields. + * Alias handling is done in the Fetcher's transformQuery via applyAliases(). + */ + +import { z } from 'zod' + +/** + * Base QueryParams schema — empty by default. + * Standard models extend this with their specific fields. + * Provider-specific models further extend the standard with extra fields. + * + * Using .passthrough() to match Python's ConfigDict(extra="allow"). + */ +export const BaseQueryParamsSchema = z.object({}).passthrough() + +export type BaseQueryParams = z.infer diff --git a/packages/opentypebb/src/core/provider/query-executor.ts b/packages/opentypebb/src/core/provider/query-executor.ts new file mode 100644 index 00000000..71a60783 --- /dev/null +++ b/packages/opentypebb/src/core/provider/query-executor.ts @@ -0,0 +1,98 @@ +/** + * Query Executor. + * Maps to: openbb_core/provider/query_executor.py + * + * Resolves provider + model name to a Fetcher class, + * filters credentials, and executes the TET pipeline. + */ + +import type { FetcherClass } from './abstract/fetcher.js' +import type { Provider } from './abstract/provider.js' +import type { Registry } from './registry.js' +import { OpenBBError } from './utils/errors.js' + +export class QueryExecutor { + constructor(private readonly registry: Registry) {} + + /** Get a provider from the registry. */ + getProvider(providerName: string): Provider { + const name = providerName.toLowerCase() + const provider = this.registry.providers.get(name) + if (!provider) { + const available = [...this.registry.providers.keys()] + throw new OpenBBError( + `Provider '${name}' not found in the registry. Available providers: ${available.join(', ')}`, + ) + } + return provider + } + + /** Get a fetcher from a provider by model name. */ + getFetcher(provider: Provider, modelName: string): FetcherClass { + const fetcher = provider.fetcherDict[modelName] + if (!fetcher) { + throw new OpenBBError( + `Fetcher not found for model '${modelName}' in provider '${provider.name}'.`, + ) + } + return fetcher + } + + /** + * Filter credentials to only include those required by the provider. + * Maps to: QueryExecutor.filter_credentials() in query_executor.py + */ + static filterCredentials( + credentials: Record | null, + provider: Provider, + requireCredentials: boolean, + ): Record { + const filtered: Record = {} + + if (provider.credentials.length > 0) { + const creds = credentials ?? {} + + for (const c of provider.credentials) { + const v = creds[c] + if (!v) { + if (requireCredentials) { + const website = provider.website ?? '' + const extraMsg = website ? ` Check ${website} to get it.` : '' + throw new OpenBBError( + `Missing credential '${c}'.${extraMsg}`, + ) + } + } else { + filtered[c] = v + } + } + } + + return filtered + } + + /** + * Execute a query against a provider. + * + * @param providerName - Name of the provider (e.g., "fmp"). + * @param modelName - Name of the model (e.g., "EquityHistorical"). + * @param params - Query parameters (e.g., { symbol: "AAPL" }). + * @param credentials - Provider credentials (e.g., { fmp_api_key: "..." }). + * @returns Query result from the fetcher. + */ + async execute( + providerName: string, + modelName: string, + params: Record, + credentials: Record | null = null, + ): Promise { + const provider = this.getProvider(providerName) + const fetcher = this.getFetcher(provider, modelName) + const filteredCredentials = QueryExecutor.filterCredentials( + credentials, + provider, + fetcher.requireCredentials, + ) + return fetcher.fetchData(params, filteredCredentials) + } +} diff --git a/packages/opentypebb/src/core/provider/registry.ts b/packages/opentypebb/src/core/provider/registry.ts new file mode 100644 index 00000000..b271bb74 --- /dev/null +++ b/packages/opentypebb/src/core/provider/registry.ts @@ -0,0 +1,24 @@ +/** + * Provider Registry. + * Maps to: openbb_core/provider/registry.py + * + * Maintains a registry of all available providers. + * In Python, RegistryLoader uses entry_points for dynamic discovery. + * In TypeScript, providers are explicitly imported and registered. + */ + +import type { Provider } from './abstract/provider.js' + +export class Registry { + private readonly _providers = new Map() + + /** Return a map of registered providers. */ + get providers(): ReadonlyMap { + return this._providers + } + + /** Include a provider in the registry. */ + includeProvider(provider: Provider): void { + this._providers.set(provider.name.toLowerCase(), provider) + } +} diff --git a/packages/opentypebb/src/core/provider/utils/errors.ts b/packages/opentypebb/src/core/provider/utils/errors.ts new file mode 100644 index 00000000..7b91fa83 --- /dev/null +++ b/packages/opentypebb/src/core/provider/utils/errors.ts @@ -0,0 +1,32 @@ +/** + * Error classes for OpenTypeBB. + * Maps to: openbb_core/app/model/abstract/error.py + * openbb_core/provider/utils/errors.py + */ + +/** Base error for all OpenBB errors. */ +export class OpenBBError extends Error { + readonly original?: unknown + + constructor(message: string, original?: unknown) { + super(message) + this.name = 'OpenBBError' + this.original = original + } +} + +/** Raised when a query returns no data. */ +export class EmptyDataError extends OpenBBError { + constructor(message = 'No data found.') { + super(message) + this.name = 'EmptyDataError' + } +} + +/** Raised when credentials are missing or invalid. */ +export class UnauthorizedError extends OpenBBError { + constructor(message = 'Unauthorized.') { + super(message) + this.name = 'UnauthorizedError' + } +} diff --git a/packages/opentypebb/src/core/provider/utils/helpers.ts b/packages/opentypebb/src/core/provider/utils/helpers.ts new file mode 100644 index 00000000..3dfdd4f8 --- /dev/null +++ b/packages/opentypebb/src/core/provider/utils/helpers.ts @@ -0,0 +1,144 @@ +/** + * HTTP helpers and utility functions. + * Maps to: openbb_core/provider/utils/helpers.py + */ + +import { OpenBBError } from './errors.js' + +/** + * Make an async HTTP request and return the parsed JSON response. + * Maps to: amake_request() in helpers.py + * + * @param url - The URL to request. + * @param options - Optional fetch options. + * @param responseCallback - Optional callback to process the response before parsing. + * @param timeoutMs - Request timeout in milliseconds (default: 30000). + * @returns Parsed JSON response. + */ +export async function amakeRequest( + url: string, + options: { + method?: string + headers?: Record + body?: string + timeoutMs?: number + responseCallback?: (response: Response) => Promise + } = {}, +): Promise { + const { method = 'GET', headers, body, timeoutMs = 30_000, responseCallback } = options + + let response: Response + try { + response = await fetch(url, { + method, + headers, + body, + signal: AbortSignal.timeout(timeoutMs), + }) + } catch (error) { + if (error instanceof DOMException && error.name === 'TimeoutError') { + throw new OpenBBError(`Request timed out after ${timeoutMs}ms: ${url}`) + } + throw new OpenBBError(`Request failed: ${url}`, error) + } + + if (responseCallback) { + response = await responseCallback(response) + } + + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new OpenBBError( + `HTTP ${response.status} ${response.statusText}: ${url}${text ? ` - ${text}` : ''}`, + ) + } + + try { + return (await response.json()) as T + } catch (error) { + throw new OpenBBError(`Failed to parse JSON response from: ${url}`, error) + } +} + +/** + * Apply alias dictionary to a data record. + * Maps to: Data.__alias_dict__ + _use_alias model_validator in data.py + * + * The alias dict maps {targetFieldName: sourceFieldName}. + * This renames source keys to target keys in the data. + * + * @param data - The raw data object. + * @param aliasDict - Mapping of {targetName: sourceName}. + * @returns Data with renamed keys. + */ +export function applyAliases( + data: Record, + aliasDict: Record, +): Record { + if (!aliasDict || Object.keys(aliasDict).length === 0) return data + + const result: Record = { ...data } + + // aliasDict maps {newName: originalName} + for (const [newName, originalName] of Object.entries(aliasDict)) { + if (originalName in result) { + result[newName] = result[originalName] + if (newName !== originalName) { + delete result[originalName] + } + } + } + + return result +} + +/** + * Replace empty strings and "NA" with null in a data record. + * Common pattern in FMP/YFinance providers. + */ +export function replaceEmptyStrings( + data: Record, +): Record { + const result: Record = {} + for (const [key, value] of Object.entries(data)) { + result[key] = value === '' || value === 'NA' ? null : value + } + return result +} + +/** + * Make an HTTP GET request using Node's native https module. + * Bypasses the undici global dispatcher (and its proxy agent). + * Useful for APIs that are incompatible with HTTP proxy tunneling + * (e.g. OECD SDMX, ECB, IMF) but are accessible via OS network stack (TUN). + */ +export async function nativeFetch( + url: string, + options: { headers?: Record; timeoutMs?: number } = {}, +): Promise<{ status: number; text: string }> { + const { headers, timeoutMs = 30_000 } = options + const mod = url.startsWith('https') ? await import('https') : await import('http') + + return new Promise((resolve, reject) => { + const req = mod.get(url, { headers, timeout: timeoutMs }, (res) => { + let body = '' + res.on('data', (chunk: Buffer) => { body += chunk.toString() }) + res.on('end', () => resolve({ status: res.statusCode ?? 0, text: body })) + }) + req.on('error', reject) + req.on('timeout', () => { req.destroy(); reject(new OpenBBError(`Request timed out: ${url}`)) }) + }) +} + +/** + * Build a query string from params, omitting null/undefined values. + */ +export function buildQueryString(params: Record): string { + const searchParams = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value !== null && value !== undefined) { + searchParams.set(key, String(value)) + } + } + return searchParams.toString() +} diff --git a/packages/opentypebb/src/core/utils/proxy.ts b/packages/opentypebb/src/core/utils/proxy.ts new file mode 100644 index 00000000..388a4f1b --- /dev/null +++ b/packages/opentypebb/src/core/utils/proxy.ts @@ -0,0 +1,20 @@ +/** + * Proxy bootstrap — makes globalThis.fetch proxy-aware via undici. + * + * Call setupProxy() ONCE at server startup, BEFORE any fetch calls. + * Reads HTTP_PROXY / HTTPS_PROXY / NO_PROXY from environment. + * If no proxy env vars are set, this is a no-op. + */ +import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici' + +export function setupProxy(): void { + const proxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY + || process.env.https_proxy || process.env.http_proxy + if (!proxy) return + + // EnvHttpProxyAgent auto-reads HTTP_PROXY/HTTPS_PROXY/NO_PROXY + const agent = new EnvHttpProxyAgent() + setGlobalDispatcher(agent) + + console.log(`[proxy] Using proxy: ${proxy}`) +} diff --git a/packages/opentypebb/src/extensions/commodity/commodity-router.ts b/packages/opentypebb/src/extensions/commodity/commodity-router.ts new file mode 100644 index 00000000..055bc687 --- /dev/null +++ b/packages/opentypebb/src/extensions/commodity/commodity-router.ts @@ -0,0 +1,35 @@ +/** + * Commodity Router. + * Maps to: openbb_commodity/commodity_router.py + */ + +import { Router } from '../../core/app/router.js' +import { commodityPriceRouter } from './price/price-router.js' + +export const commodityRouter = new Router({ + prefix: '/commodity', + description: 'Commodity market data.', +}) + +// --- Include sub-routers --- +commodityRouter.includeRouter(commodityPriceRouter) + +// --- Root-level commands --- + +commodityRouter.command({ + model: 'PetroleumStatusReport', + path: '/petroleum_status_report', + description: 'Get EIA Weekly Petroleum Status Report data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'PetroleumStatusReport', params, credentials) + }, +}) + +commodityRouter.command({ + model: 'ShortTermEnergyOutlook', + path: '/short_term_energy_outlook', + description: 'Get EIA Short-Term Energy Outlook (STEO) data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'ShortTermEnergyOutlook', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/commodity/price/price-router.ts b/packages/opentypebb/src/extensions/commodity/price/price-router.ts new file mode 100644 index 00000000..b212a748 --- /dev/null +++ b/packages/opentypebb/src/extensions/commodity/price/price-router.ts @@ -0,0 +1,20 @@ +/** + * Commodity Price Sub-Router. + * Maps to: openbb_commodity/price/ + */ + +import { Router } from '../../../core/app/router.js' + +export const commodityPriceRouter = new Router({ + prefix: '/price', + description: 'Commodity price data.', +}) + +commodityPriceRouter.command({ + model: 'CommoditySpotPrice', + path: '/spot', + description: 'Get historical spot/futures prices for commodities.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CommoditySpotPrice', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/crypto/crypto-router.ts b/packages/opentypebb/src/extensions/crypto/crypto-router.ts new file mode 100644 index 00000000..bdee31e2 --- /dev/null +++ b/packages/opentypebb/src/extensions/crypto/crypto-router.ts @@ -0,0 +1,27 @@ +/** + * Crypto Router — root router for cryptocurrency market data. + * Maps to: openbb_crypto/crypto_router.py + */ + +import { Router } from '../../core/app/router.js' +import { cryptoPriceRouter } from './price/price-router.js' + +export const cryptoRouter = new Router({ + prefix: '/crypto', + description: 'Cryptocurrency market data.', +}) + +// --- Include sub-routers --- + +cryptoRouter.includeRouter(cryptoPriceRouter) + +// --- Root-level commands --- + +cryptoRouter.command({ + model: 'CryptoSearch', + path: '/search', + description: 'Search available cryptocurrency pairs within a provider.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CryptoSearch', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/crypto/price/price-router.ts b/packages/opentypebb/src/extensions/crypto/price/price-router.ts new file mode 100644 index 00000000..0544724e --- /dev/null +++ b/packages/opentypebb/src/extensions/crypto/price/price-router.ts @@ -0,0 +1,20 @@ +/** + * Crypto Price Router. + * Maps to: openbb_crypto/price/price_router.py + */ + +import { Router } from '../../../core/app/router.js' + +export const cryptoPriceRouter = new Router({ + prefix: '/price', + description: 'Cryptocurrency price data.', +}) + +cryptoPriceRouter.command({ + model: 'CryptoHistorical', + path: '/historical', + description: 'Get historical price data for cryptocurrency pair(s).', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CryptoHistorical', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/currency/currency-router.ts b/packages/opentypebb/src/extensions/currency/currency-router.ts new file mode 100644 index 00000000..fc7a3cda --- /dev/null +++ b/packages/opentypebb/src/extensions/currency/currency-router.ts @@ -0,0 +1,45 @@ +/** + * Currency Router — root router for foreign exchange market data. + * Maps to: openbb_currency/currency_router.py + */ + +import { Router } from '../../core/app/router.js' +import { currencyPriceRouter } from './price/price-router.js' + +export const currencyRouter = new Router({ + prefix: '/currency', + description: 'Foreign exchange (FX) market data.', +}) + +// --- Include sub-routers --- + +currencyRouter.includeRouter(currencyPriceRouter) + +// --- Root-level commands --- + +currencyRouter.command({ + model: 'CurrencyPairs', + path: '/search', + description: 'Search available currency pairs.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CurrencyPairs', params, credentials) + }, +}) + +currencyRouter.command({ + model: 'CurrencyReferenceRates', + path: '/reference_rates', + description: 'Get current, official, currency reference rates.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CurrencyReferenceRates', params, credentials) + }, +}) + +currencyRouter.command({ + model: 'CurrencySnapshots', + path: '/snapshots', + description: 'Get snapshots of currency exchange rates.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CurrencySnapshots', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/currency/price/price-router.ts b/packages/opentypebb/src/extensions/currency/price/price-router.ts new file mode 100644 index 00000000..2c69d8f5 --- /dev/null +++ b/packages/opentypebb/src/extensions/currency/price/price-router.ts @@ -0,0 +1,20 @@ +/** + * Currency Price Router. + * Maps to: openbb_currency/price/price_router.py + */ + +import { Router } from '../../../core/app/router.js' + +export const currencyPriceRouter = new Router({ + prefix: '/price', + description: 'Currency price data.', +}) + +currencyPriceRouter.command({ + model: 'CurrencyHistorical', + path: '/historical', + description: 'Get historical price data for a currency pair.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CurrencyHistorical', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/derivatives/derivatives-router.ts b/packages/opentypebb/src/extensions/derivatives/derivatives-router.ts new file mode 100644 index 00000000..12833bb9 --- /dev/null +++ b/packages/opentypebb/src/extensions/derivatives/derivatives-router.ts @@ -0,0 +1,74 @@ +/** + * Derivatives Router — root router for derivatives market data. + * Maps to: openbb_derivatives/derivatives_router.py + */ + +import { Router } from '../../core/app/router.js' + +export const derivativesRouter = new Router({ + prefix: '/derivatives', + description: 'Derivatives market data.', +}) + +derivativesRouter.command({ + model: 'FuturesHistorical', + path: '/futures/historical', + description: 'Get historical price data for futures contracts.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'FuturesHistorical', params, credentials) + }, +}) + +derivativesRouter.command({ + model: 'FuturesCurve', + path: '/futures/curve', + description: 'Get the futures term structure (curve) for a symbol.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'FuturesCurve', params, credentials) + }, +}) + +derivativesRouter.command({ + model: 'FuturesInfo', + path: '/futures/info', + description: 'Get information about futures contracts.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'FuturesInfo', params, credentials) + }, +}) + +derivativesRouter.command({ + model: 'FuturesInstruments', + path: '/futures/instruments', + description: 'Get the list of available futures instruments.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'FuturesInstruments', params, credentials) + }, +}) + +derivativesRouter.command({ + model: 'OptionsChains', + path: '/options/chains', + description: 'Get the complete options chain for a given symbol.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'OptionsChains', params, credentials) + }, +}) + +derivativesRouter.command({ + model: 'OptionsSnapshots', + path: '/options/snapshots', + description: 'Get current snapshot data for options.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'OptionsSnapshots', params, credentials) + }, +}) + +derivativesRouter.command({ + model: 'OptionsUnusual', + path: '/options/unusual', + description: 'Get unusual options activity data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'OptionsUnusual', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/economy/economy-router.ts b/packages/opentypebb/src/extensions/economy/economy-router.ts new file mode 100644 index 00000000..6a121dc8 --- /dev/null +++ b/packages/opentypebb/src/extensions/economy/economy-router.ts @@ -0,0 +1,279 @@ +/** + * Economy Router. + * Maps to: openbb_economy/economy_router.py + */ + +import { Router } from '../../core/app/router.js' +import { surveyRouter } from './survey/survey-router.js' +import { gdpRouter } from './gdp/gdp-router.js' +import { shippingRouter } from './shipping/shipping-router.js' + +export const economyRouter = new Router({ + prefix: '/economy', + description: 'Economic data.', +}) + +// --- Include sub-routers --- +economyRouter.includeRouter(surveyRouter) +economyRouter.includeRouter(gdpRouter) +economyRouter.includeRouter(shippingRouter) + +// --- Root-level commands --- + +economyRouter.command({ + model: 'EconomicCalendar', + path: '/calendar', + description: 'Get the upcoming and historical economic calendar events.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EconomicCalendar', params, credentials) + }, +}) + +economyRouter.command({ + model: 'TreasuryRates', + path: '/treasury_rates', + description: 'Get current and historical Treasury rates.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'TreasuryRates', params, credentials) + }, +}) + +economyRouter.command({ + model: 'DiscoveryFilings', + path: '/discovery_filings', + description: 'Search and discover SEC filings by form type and date range.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'DiscoveryFilings', params, credentials) + }, +}) + +economyRouter.command({ + model: 'AvailableIndicators', + path: '/available_indicators', + description: 'Get the list of available economic indicators.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'AvailableIndicators', params, credentials) + }, +}) + +economyRouter.command({ + model: 'ConsumerPriceIndex', + path: '/cpi', + description: 'Get Consumer Price Index (CPI) data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'ConsumerPriceIndex', params, credentials) + }, +}) + +economyRouter.command({ + model: 'CompositeLeadingIndicator', + path: '/composite_leading_indicator', + description: 'Get Composite Leading Indicator (CLI) data from the OECD.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CompositeLeadingIndicator', params, credentials) + }, +}) + +economyRouter.command({ + model: 'CountryInterestRates', + path: '/interest_rates', + description: 'Get short-term interest rates by country.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CountryInterestRates', params, credentials) + }, +}) + +economyRouter.command({ + model: 'BalanceOfPayments', + path: '/balance_of_payments', + description: 'Get balance of payments data from the ECB.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'BalanceOfPayments', params, credentials) + }, +}) + +economyRouter.command({ + model: 'CentralBankHoldings', + path: '/central_bank_holdings', + description: 'Get central bank holdings data (Fed balance sheet).', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CentralBankHoldings', params, credentials) + }, +}) + +economyRouter.command({ + model: 'CountryProfile', + path: '/country_profile', + description: 'Get a comprehensive economic profile for a country.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CountryProfile', params, credentials) + }, +}) + +economyRouter.command({ + model: 'DirectionOfTrade', + path: '/direction_of_trade', + description: 'Get direction of trade statistics from the IMF.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'DirectionOfTrade', params, credentials) + }, +}) + +economyRouter.command({ + model: 'ExportDestinations', + path: '/export_destinations', + description: 'Get top export destinations for a country.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'ExportDestinations', params, credentials) + }, +}) + +economyRouter.command({ + model: 'EconomicIndicators', + path: '/indicators', + description: 'Get economic indicator time series data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EconomicIndicators', params, credentials) + }, +}) + +economyRouter.command({ + model: 'RiskPremium', + path: '/risk_premium', + description: 'Get market risk premium by country.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'RiskPremium', params, credentials) + }, +}) + +// --- FRED endpoints --- + +economyRouter.command({ + model: 'FredSearch', + path: '/fred_search', + description: 'Search FRED economic data series.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'FredSearch', params, credentials) + }, +}) + +economyRouter.command({ + model: 'FredSeries', + path: '/fred_series', + description: 'Get FRED series observations.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'FredSeries', params, credentials) + }, +}) + +economyRouter.command({ + model: 'FredReleaseTable', + path: '/fred_release_table', + description: 'Get FRED release table data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'FredReleaseTable', params, credentials) + }, +}) + +economyRouter.command({ + model: 'FredRegional', + path: '/fred_regional', + description: 'Get FRED regional (GeoFRED) data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'FredRegional', params, credentials) + }, +}) + +// --- Macro indicators --- + +economyRouter.command({ + model: 'Unemployment', + path: '/unemployment', + description: 'Get unemployment rate data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'Unemployment', params, credentials) + }, +}) + +economyRouter.command({ + model: 'MoneyMeasures', + path: '/money_measures', + description: 'Get money supply measures (M1, M2).', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'MoneyMeasures', params, credentials) + }, +}) + +economyRouter.command({ + model: 'PersonalConsumptionExpenditures', + path: '/pce', + description: 'Get Personal Consumption Expenditures (PCE) price index.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'PersonalConsumptionExpenditures', params, credentials) + }, +}) + +economyRouter.command({ + model: 'TotalFactorProductivity', + path: '/total_factor_productivity', + description: 'Get total factor productivity data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'TotalFactorProductivity', params, credentials) + }, +}) + +economyRouter.command({ + model: 'FomcDocuments', + path: '/fomc_documents', + description: 'Get FOMC meeting documents and rate decisions.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'FomcDocuments', params, credentials) + }, +}) + +economyRouter.command({ + model: 'PrimaryDealerPositioning', + path: '/primary_dealer_positioning', + description: 'Get primary dealer positioning data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'PrimaryDealerPositioning', params, credentials) + }, +}) + +economyRouter.command({ + model: 'PrimaryDealerFails', + path: '/primary_dealer_fails', + description: 'Get primary dealer fails-to-deliver data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'PrimaryDealerFails', params, credentials) + }, +}) + +// --- OECD endpoints --- + +economyRouter.command({ + model: 'SharePriceIndex', + path: '/share_price_index', + description: 'Get share price index data from OECD.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'SharePriceIndex', params, credentials) + }, +}) + +economyRouter.command({ + model: 'HousePriceIndex', + path: '/house_price_index', + description: 'Get house price index data from OECD.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'HousePriceIndex', params, credentials) + }, +}) + +economyRouter.command({ + model: 'RetailPrices', + path: '/retail_prices', + description: 'Get retail price data from OECD.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'RetailPrices', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/economy/gdp/gdp-router.ts b/packages/opentypebb/src/extensions/economy/gdp/gdp-router.ts new file mode 100644 index 00000000..5cdd4434 --- /dev/null +++ b/packages/opentypebb/src/extensions/economy/gdp/gdp-router.ts @@ -0,0 +1,38 @@ +/** + * Economy GDP Sub-Router. + * Maps to: openbb_economy/gdp/gdp_router.py + */ + +import { Router } from '../../../core/app/router.js' + +export const gdpRouter = new Router({ + prefix: '/gdp', + description: 'GDP data.', +}) + +gdpRouter.command({ + model: 'GdpForecast', + path: '/forecast', + description: 'Get GDP forecast data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'GdpForecast', params, credentials) + }, +}) + +gdpRouter.command({ + model: 'GdpNominal', + path: '/nominal', + description: 'Get nominal GDP data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'GdpNominal', params, credentials) + }, +}) + +gdpRouter.command({ + model: 'GdpReal', + path: '/real', + description: 'Get real GDP data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'GdpReal', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/economy/shipping/shipping-router.ts b/packages/opentypebb/src/extensions/economy/shipping/shipping-router.ts new file mode 100644 index 00000000..08ec3f2b --- /dev/null +++ b/packages/opentypebb/src/extensions/economy/shipping/shipping-router.ts @@ -0,0 +1,50 @@ +/** + * Shipping Sub-Router. + * Maps to: openbb_economy/shipping/ + * + * Note: These endpoints are stubs — they register the routes but always + * throw EmptyDataError until a reliable public data source is integrated. + */ + +import { Router } from '../../../core/app/router.js' + +export const shippingRouter = new Router({ + prefix: '/shipping', + description: 'Global shipping and trade route data.', +}) + +shippingRouter.command({ + model: 'PortInfo', + path: '/port_info', + description: 'Get information about a port.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'PortInfo', params, credentials) + }, +}) + +shippingRouter.command({ + model: 'PortVolume', + path: '/port_volume', + description: 'Get shipping volume data for a port.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'PortVolume', params, credentials) + }, +}) + +shippingRouter.command({ + model: 'ChokepointInfo', + path: '/chokepoint_info', + description: 'Get information about a maritime chokepoint.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'ChokepointInfo', params, credentials) + }, +}) + +shippingRouter.command({ + model: 'ChokepointVolume', + path: '/chokepoint_volume', + description: 'Get transit volume data for a maritime chokepoint.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'ChokepointVolume', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/economy/survey/survey-router.ts b/packages/opentypebb/src/extensions/economy/survey/survey-router.ts new file mode 100644 index 00000000..97a9fd4f --- /dev/null +++ b/packages/opentypebb/src/extensions/economy/survey/survey-router.ts @@ -0,0 +1,92 @@ +/** + * Economy Survey Sub-Router. + * Maps to: openbb_economy/survey/survey_router.py + */ + +import { Router } from '../../../core/app/router.js' + +export const surveyRouter = new Router({ + prefix: '/survey', + description: 'Economic survey data.', +}) + +surveyRouter.command({ + model: 'NonfarmPayrolls', + path: '/nonfarm_payrolls', + description: 'Get nonfarm payrolls data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'NonfarmPayrolls', params, credentials) + }, +}) + +surveyRouter.command({ + model: 'InflationExpectations', + path: '/inflation_expectations', + description: 'Get inflation expectations data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'InflationExpectations', params, credentials) + }, +}) + +surveyRouter.command({ + model: 'Sloos', + path: '/sloos', + description: 'Get Senior Loan Officer Opinion Survey (SLOOS) data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'Sloos', params, credentials) + }, +}) + +surveyRouter.command({ + model: 'UniversityOfMichigan', + path: '/university_of_michigan', + description: 'Get University of Michigan Consumer Sentiment data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'UniversityOfMichigan', params, credentials) + }, +}) + +surveyRouter.command({ + model: 'EconomicConditionsChicago', + path: '/economic_conditions_chicago', + description: 'Get Chicago Fed National Activity Index data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EconomicConditionsChicago', params, credentials) + }, +}) + +surveyRouter.command({ + model: 'ManufacturingOutlookTexas', + path: '/manufacturing_outlook_texas', + description: 'Get Dallas Fed Manufacturing Outlook Survey data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'ManufacturingOutlookTexas', params, credentials) + }, +}) + +surveyRouter.command({ + model: 'ManufacturingOutlookNY', + path: '/manufacturing_outlook_ny', + description: 'Get NY Fed Empire State Manufacturing Survey data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'ManufacturingOutlookNY', params, credentials) + }, +}) + +surveyRouter.command({ + model: 'BlsSeries', + path: '/bls_series', + description: 'Get BLS time series data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'BlsSeries', params, credentials) + }, +}) + +surveyRouter.command({ + model: 'BlsSearch', + path: '/bls_search', + description: 'Search BLS data series.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'BlsSearch', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/equity/calendar/calendar-router.ts b/packages/opentypebb/src/extensions/equity/calendar/calendar-router.ts new file mode 100644 index 00000000..498e3a3e --- /dev/null +++ b/packages/opentypebb/src/extensions/equity/calendar/calendar-router.ts @@ -0,0 +1,56 @@ +/** + * Equity Calendar Router. + * Maps to: openbb_equity/calendar/calendar_router.py + */ + +import { Router } from '../../../core/app/router.js' + +export const calendarRouter = new Router({ + prefix: '/calendar', + description: 'Equity calendar data.', +}) + +calendarRouter.command({ + model: 'CalendarIpo', + path: '/ipo', + description: 'Get historical and upcoming initial public offerings (IPOs).', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CalendarIpo', params, credentials) + }, +}) + +calendarRouter.command({ + model: 'CalendarDividend', + path: '/dividend', + description: 'Get historical and upcoming dividend payments.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CalendarDividend', params, credentials) + }, +}) + +calendarRouter.command({ + model: 'CalendarSplits', + path: '/splits', + description: 'Get historical and upcoming stock split operations.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CalendarSplits', params, credentials) + }, +}) + +calendarRouter.command({ + model: 'CalendarEvents', + path: '/events', + description: 'Get historical and upcoming company events.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CalendarEvents', params, credentials) + }, +}) + +calendarRouter.command({ + model: 'CalendarEarnings', + path: '/earnings', + description: 'Get historical and upcoming company earnings releases.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CalendarEarnings', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/equity/compare/compare-router.ts b/packages/opentypebb/src/extensions/equity/compare/compare-router.ts new file mode 100644 index 00000000..32ba0f2a --- /dev/null +++ b/packages/opentypebb/src/extensions/equity/compare/compare-router.ts @@ -0,0 +1,38 @@ +/** + * Equity Compare Router. + * Maps to: openbb_equity/compare/compare_router.py + */ + +import { Router } from '../../../core/app/router.js' + +export const compareRouter = new Router({ + prefix: '/compare', + description: 'Equity comparison data.', +}) + +compareRouter.command({ + model: 'EquityPeers', + path: '/peers', + description: 'Get the closest peers for a given company.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EquityPeers', params, credentials) + }, +}) + +compareRouter.command({ + model: 'CompareGroups', + path: '/groups', + description: 'Get company data grouped by sector, industry, or country.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CompareGroups', params, credentials) + }, +}) + +compareRouter.command({ + model: 'CompareCompanyFacts', + path: '/company_facts', + description: 'Compare reported company facts and fundamental data points.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CompareCompanyFacts', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/equity/discovery/discovery-router.ts b/packages/opentypebb/src/extensions/equity/discovery/discovery-router.ts new file mode 100644 index 00000000..a8a08017 --- /dev/null +++ b/packages/opentypebb/src/extensions/equity/discovery/discovery-router.ts @@ -0,0 +1,101 @@ +/** + * Equity Discovery Router. + * Maps to: openbb_equity/discovery/discovery_router.py + */ + +import { Router } from '../../../core/app/router.js' + +export const discoveryRouter = new Router({ + prefix: '/discovery', + description: 'Equity discovery data.', +}) + +discoveryRouter.command({ + model: 'EquityGainers', + path: '/gainers', + description: 'Get the top price gainers in the stock market.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EquityGainers', params, credentials) + }, +}) + +discoveryRouter.command({ + model: 'EquityLosers', + path: '/losers', + description: 'Get the top price losers in the stock market.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EquityLosers', params, credentials) + }, +}) + +discoveryRouter.command({ + model: 'EquityActive', + path: '/active', + description: 'Get the most actively traded stocks based on volume.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EquityActive', params, credentials) + }, +}) + +discoveryRouter.command({ + model: 'EquityUndervaluedLargeCaps', + path: '/undervalued_large_caps', + description: 'Get potentially undervalued large cap stocks.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EquityUndervaluedLargeCaps', params, credentials) + }, +}) + +discoveryRouter.command({ + model: 'EquityUndervaluedGrowth', + path: '/undervalued_growth', + description: 'Get potentially undervalued growth stocks.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EquityUndervaluedGrowth', params, credentials) + }, +}) + +discoveryRouter.command({ + model: 'EquityAggressiveSmallCaps', + path: '/aggressive_small_caps', + description: 'Get top small cap stocks based on earnings growth.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EquityAggressiveSmallCaps', params, credentials) + }, +}) + +discoveryRouter.command({ + model: 'GrowthTechEquities', + path: '/growth_tech', + description: 'Get top tech stocks based on revenue and earnings growth.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'GrowthTechEquities', params, credentials) + }, +}) + +discoveryRouter.command({ + model: 'TopRetail', + path: '/top_retail', + description: 'Track over $30B USD/day of individual investors trades.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'TopRetail', params, credentials) + }, +}) + +discoveryRouter.command({ + model: 'DiscoveryFilings', + path: '/filings', + description: 'Get the URLs to SEC filings reported to the EDGAR database.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'DiscoveryFilings', params, credentials) + }, +}) + +discoveryRouter.command({ + model: 'LatestFinancialReports', + path: '/latest_financial_reports', + description: 'Get the newest quarterly, annual, and current reports for all companies.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'LatestFinancialReports', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/equity/equity-router.ts b/packages/opentypebb/src/extensions/equity/equity-router.ts new file mode 100644 index 00000000..7c80f0e0 --- /dev/null +++ b/packages/opentypebb/src/extensions/equity/equity-router.ts @@ -0,0 +1,78 @@ +/** + * Equity Router — root router for equity market data. + * Maps to: openbb_equity/equity_router.py + * + * Includes sub-routers for price, fundamental, discovery, calendar, + * estimates, ownership, and compare. + */ + +import { Router } from '../../core/app/router.js' +import { priceRouter } from './price/price-router.js' +import { fundamentalRouter } from './fundamental/fundamental-router.js' +import { discoveryRouter } from './discovery/discovery-router.js' +import { calendarRouter } from './calendar/calendar-router.js' +import { estimatesRouter } from './estimates/estimates-router.js' +import { ownershipRouter } from './ownership/ownership-router.js' +import { compareRouter } from './compare/compare-router.js' + +export const equityRouter = new Router({ + prefix: '/equity', + description: 'Equity market data.', +}) + +// --- Include sub-routers --- + +equityRouter.includeRouter(priceRouter) +equityRouter.includeRouter(fundamentalRouter) +equityRouter.includeRouter(discoveryRouter) +equityRouter.includeRouter(calendarRouter) +equityRouter.includeRouter(estimatesRouter) +equityRouter.includeRouter(ownershipRouter) +equityRouter.includeRouter(compareRouter) + +// --- Root-level commands --- + +equityRouter.command({ + model: 'EquitySearch', + path: '/search', + description: 'Search for stock symbol, CIK, LEI, or company name.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EquitySearch', params, credentials) + }, +}) + +equityRouter.command({ + model: 'EquityScreener', + path: '/screener', + description: 'Screen for companies meeting various criteria.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EquityScreener', params, credentials) + }, +}) + +equityRouter.command({ + model: 'EquityInfo', + path: '/profile', + description: 'Get general information about a company.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EquityInfo', params, credentials) + }, +}) + +equityRouter.command({ + model: 'MarketSnapshots', + path: '/market_snapshots', + description: 'Get an updated equity market snapshot.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'MarketSnapshots', params, credentials) + }, +}) + +equityRouter.command({ + model: 'HistoricalMarketCap', + path: '/historical_market_cap', + description: 'Get the historical market cap of a ticker symbol.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'HistoricalMarketCap', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/equity/estimates/estimates-router.ts b/packages/opentypebb/src/extensions/equity/estimates/estimates-router.ts new file mode 100644 index 00000000..30086f8c --- /dev/null +++ b/packages/opentypebb/src/extensions/equity/estimates/estimates-router.ts @@ -0,0 +1,83 @@ +/** + * Equity Estimates Router. + * Maps to: openbb_equity/estimates/estimates_router.py + */ + +import { Router } from '../../../core/app/router.js' + +export const estimatesRouter = new Router({ + prefix: '/estimates', + description: 'Analyst estimates and price targets.', +}) + +estimatesRouter.command({ + model: 'PriceTarget', + path: '/price_target', + description: 'Get analyst price targets by company.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'PriceTarget', params, credentials) + }, +}) + +estimatesRouter.command({ + model: 'AnalystEstimates', + path: '/historical', + description: 'Get historical analyst estimates for earnings and revenue.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'AnalystEstimates', params, credentials) + }, +}) + +estimatesRouter.command({ + model: 'PriceTargetConsensus', + path: '/consensus', + description: 'Get consensus price target and recommendation.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'PriceTargetConsensus', params, credentials) + }, +}) + +estimatesRouter.command({ + model: 'AnalystSearch', + path: '/analyst_search', + description: 'Search for specific analysts and get their forecast track record.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'AnalystSearch', params, credentials) + }, +}) + +estimatesRouter.command({ + model: 'ForwardSalesEstimates', + path: '/forward_sales', + description: 'Get forward sales estimates.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'ForwardSalesEstimates', params, credentials) + }, +}) + +estimatesRouter.command({ + model: 'ForwardEbitdaEstimates', + path: '/forward_ebitda', + description: 'Get forward EBITDA estimates.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'ForwardEbitdaEstimates', params, credentials) + }, +}) + +estimatesRouter.command({ + model: 'ForwardEpsEstimates', + path: '/forward_eps', + description: 'Get forward EPS estimates.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'ForwardEpsEstimates', params, credentials) + }, +}) + +estimatesRouter.command({ + model: 'ForwardPeEstimates', + path: '/forward_pe', + description: 'Get forward PE estimates.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'ForwardPeEstimates', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/equity/fundamental/fundamental-router.ts b/packages/opentypebb/src/extensions/equity/fundamental/fundamental-router.ts new file mode 100644 index 00000000..6fb21132 --- /dev/null +++ b/packages/opentypebb/src/extensions/equity/fundamental/fundamental-router.ts @@ -0,0 +1,236 @@ +/** + * Equity Fundamental Router. + * Maps to: openbb_equity/fundamental/fundamental_router.py + */ + +import { Router } from '../../../core/app/router.js' + +export const fundamentalRouter = new Router({ + prefix: '/fundamental', + description: 'Fundamental analysis data.', +}) + +fundamentalRouter.command({ + model: 'BalanceSheet', + path: '/balance', + description: 'Get the balance sheet for a given company.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'BalanceSheet', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'BalanceSheetGrowth', + path: '/balance_growth', + description: 'Get the growth of a company\'s balance sheet items over time.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'BalanceSheetGrowth', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'CashFlowStatement', + path: '/cash', + description: 'Get the cash flow statement for a given company.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CashFlowStatement', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'ReportedFinancials', + path: '/reported_financials', + description: 'Get financial statements as reported by the company.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'ReportedFinancials', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'CashFlowStatementGrowth', + path: '/cash_growth', + description: 'Get the growth of a company\'s cash flow statement items over time.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CashFlowStatementGrowth', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'HistoricalDividends', + path: '/dividends', + description: 'Get historical dividend data for a given company.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'HistoricalDividends', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'HistoricalEps', + path: '/historical_eps', + description: 'Get historical earnings per share data for a given company.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'HistoricalEps', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'HistoricalEmployees', + path: '/employee_count', + description: 'Get historical employee count data for a given company.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'HistoricalEmployees', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'SearchAttributes', + path: '/search_attributes', + description: 'Search Intrinio data tags to search in latest or historical attributes.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'SearchAttributes', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'LatestAttributes', + path: '/latest_attributes', + description: 'Get the latest value of a data tag from Intrinio.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'LatestAttributes', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'HistoricalAttributes', + path: '/historical_attributes', + description: 'Get the historical values of a data tag from Intrinio.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'HistoricalAttributes', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'IncomeStatement', + path: '/income', + description: 'Get the income statement for a given company.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'IncomeStatement', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'IncomeStatementGrowth', + path: '/income_growth', + description: 'Get the growth of a company\'s income statement items over time.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'IncomeStatementGrowth', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'KeyMetrics', + path: '/metrics', + description: 'Get fundamental metrics for a given company.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'KeyMetrics', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'KeyExecutives', + path: '/management', + description: 'Get executive management team data for a given company.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'KeyExecutives', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'ExecutiveCompensation', + path: '/management_compensation', + description: 'Get executive management team compensation for a given company over time.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'ExecutiveCompensation', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'FinancialRatios', + path: '/ratios', + description: 'Get an extensive set of financial and accounting ratios for a given company over time.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'FinancialRatios', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'RevenueGeographic', + path: '/revenue_per_geography', + description: 'Get the geographic revenue breakdown for a given company over time.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'RevenueGeographic', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'RevenueBusinessLine', + path: '/revenue_per_segment', + description: 'Get the revenue breakdown by business segment for a given company over time.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'RevenueBusinessLine', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'CompanyFilings', + path: '/filings', + description: 'Get the URLs to SEC filings reported to the EDGAR database.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CompanyFilings', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'HistoricalSplits', + path: '/historical_splits', + description: 'Get historical stock splits for a given company.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'HistoricalSplits', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'EarningsCallTranscript', + path: '/transcript', + description: 'Get earnings call transcripts for a given company.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EarningsCallTranscript', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'TrailingDividendYield', + path: '/trailing_dividend_yield', + description: 'Get the 1 year trailing dividend yield for a given company over time.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'TrailingDividendYield', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'ManagementDiscussionAnalysis', + path: '/management_discussion_analysis', + description: 'Get the Management Discussion & Analysis section from financial statements.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'ManagementDiscussionAnalysis', params, credentials) + }, +}) + +fundamentalRouter.command({ + model: 'EsgScore', + path: '/esg_score', + description: 'Get ESG (Environmental, Social, and Governance) scores from company disclosures.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EsgScore', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/equity/ownership/ownership-router.ts b/packages/opentypebb/src/extensions/equity/ownership/ownership-router.ts new file mode 100644 index 00000000..74b925b7 --- /dev/null +++ b/packages/opentypebb/src/extensions/equity/ownership/ownership-router.ts @@ -0,0 +1,65 @@ +/** + * Equity Ownership Router. + * Maps to: openbb_equity/ownership/ownership_router.py + */ + +import { Router } from '../../../core/app/router.js' + +export const ownershipRouter = new Router({ + prefix: '/ownership', + description: 'Equity ownership data.', +}) + +ownershipRouter.command({ + model: 'EquityOwnership', + path: '/major_holders', + description: 'Get data about major holders for a given company over time.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EquityOwnership', params, credentials) + }, +}) + +ownershipRouter.command({ + model: 'InstitutionalOwnership', + path: '/institutional', + description: 'Get net statistics on institutional ownership, reported on 13-F filings.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'InstitutionalOwnership', params, credentials) + }, +}) + +ownershipRouter.command({ + model: 'InsiderTrading', + path: '/insider_trading', + description: 'Get data about trading by a company\'s management team and board of directors.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'InsiderTrading', params, credentials) + }, +}) + +ownershipRouter.command({ + model: 'ShareStatistics', + path: '/share_statistics', + description: 'Get data about share float for a given company.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'ShareStatistics', params, credentials) + }, +}) + +ownershipRouter.command({ + model: 'Form13FHR', + path: '/form_13f', + description: 'Get the form 13F for institutional investment managers with $100M+ AUM.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'Form13FHR', params, credentials) + }, +}) + +ownershipRouter.command({ + model: 'GovernmentTrades', + path: '/government_trades', + description: 'Get government transaction data (Senate and House of Representatives).', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'GovernmentTrades', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/equity/price/price-router.ts b/packages/opentypebb/src/extensions/equity/price/price-router.ts new file mode 100644 index 00000000..c219f0e8 --- /dev/null +++ b/packages/opentypebb/src/extensions/equity/price/price-router.ts @@ -0,0 +1,47 @@ +/** + * Equity Price Router. + * Maps to: openbb_equity/price/price_router.py + */ + +import { Router } from '../../../core/app/router.js' + +export const priceRouter = new Router({ + prefix: '/price', + description: 'Equity price data.', +}) + +priceRouter.command({ + model: 'EquityQuote', + path: '/quote', + description: 'Get the latest quote for a given stock. This includes price, volume, and other data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EquityQuote', params, credentials) + }, +}) + +priceRouter.command({ + model: 'EquityNBBO', + path: '/nbbo', + description: 'Get the National Best Bid and Offer for a given stock.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EquityNBBO', params, credentials) + }, +}) + +priceRouter.command({ + model: 'EquityHistorical', + path: '/historical', + description: 'Get historical price data for a given stock. This includes open, high, low, close, and volume.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EquityHistorical', params, credentials) + }, +}) + +priceRouter.command({ + model: 'PricePerformance', + path: '/performance', + description: 'Get price performance data for a given stock over various time periods.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'PricePerformance', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/etf/etf-router.ts b/packages/opentypebb/src/extensions/etf/etf-router.ts new file mode 100644 index 00000000..1c830aa3 --- /dev/null +++ b/packages/opentypebb/src/extensions/etf/etf-router.ts @@ -0,0 +1,74 @@ +/** + * ETF Router. + * Maps to: openbb_platform/extensions/etf/etf_router.py + */ + +import { Router } from '../../core/app/router.js' + +export const etfRouter = new Router({ + prefix: '/etf', + description: 'Exchange Traded Fund (ETF) data.', +}) + +etfRouter.command({ + model: 'EtfSearch', + path: '/search', + description: 'Search for ETFs.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EtfSearch', params, credentials) + }, +}) + +etfRouter.command({ + model: 'EtfInfo', + path: '/info', + description: 'Get ETF information.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EtfInfo', params, credentials) + }, +}) + +etfRouter.command({ + model: 'EtfHoldings', + path: '/holdings', + description: 'Get an ETF holdings data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EtfHoldings', params, credentials) + }, +}) + +etfRouter.command({ + model: 'EtfSectors', + path: '/sectors', + description: 'Get ETF sector weightings.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EtfSectors', params, credentials) + }, +}) + +etfRouter.command({ + model: 'EtfCountries', + path: '/countries', + description: 'Get ETF country weightings.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EtfCountries', params, credentials) + }, +}) + +etfRouter.command({ + model: 'EtfEquityExposure', + path: '/equity_exposure', + description: 'Get the ETF equity exposure data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EtfEquityExposure', params, credentials) + }, +}) + +etfRouter.command({ + model: 'EtfHistorical', + path: '/historical', + description: 'Get historical ETF data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'EtfHistorical', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/index/index-router.ts b/packages/opentypebb/src/extensions/index/index-router.ts new file mode 100644 index 00000000..4bacd894 --- /dev/null +++ b/packages/opentypebb/src/extensions/index/index-router.ts @@ -0,0 +1,83 @@ +/** + * Index Router — root router for index market data. + * Maps to: openbb_index/index_router.py + */ + +import { Router } from '../../core/app/router.js' + +export const indexRouter = new Router({ + prefix: '/index', + description: 'Index market data.', +}) + +indexRouter.command({ + model: 'AvailableIndices', + path: '/available', + description: 'Get the list of available indices.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'AvailableIndices', params, credentials) + }, +}) + +indexRouter.command({ + model: 'IndexConstituents', + path: '/constituents', + description: 'Get the constituents of an index.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'IndexConstituents', params, credentials) + }, +}) + +indexRouter.command({ + model: 'IndexHistorical', + path: '/price/historical', + description: 'Get historical price data for an index.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'IndexHistorical', params, credentials) + }, +}) + +indexRouter.command({ + model: 'IndexSnapshots', + path: '/snapshots', + description: 'Get current snapshot data for indices.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'IndexSnapshots', params, credentials) + }, +}) + +indexRouter.command({ + model: 'RiskPremium', + path: '/risk_premium', + description: 'Get market risk premium data by country.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'RiskPremium', params, credentials) + }, +}) + +indexRouter.command({ + model: 'IndexSearch', + path: '/search', + description: 'Search for indices by name or symbol.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'IndexSearch', params, credentials) + }, +}) + +indexRouter.command({ + model: 'IndexSectors', + path: '/sectors', + description: 'Get sector weightings for an index.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'IndexSectors', params, credentials) + }, +}) + +indexRouter.command({ + model: 'SP500Multiples', + path: '/sp500_multiples', + description: 'Get historical S&P 500 multiples (PE ratio, earnings yield, etc).', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'SP500Multiples', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/extensions/news/news-router.ts b/packages/opentypebb/src/extensions/news/news-router.ts new file mode 100644 index 00000000..c27db71f --- /dev/null +++ b/packages/opentypebb/src/extensions/news/news-router.ts @@ -0,0 +1,29 @@ +/** + * News Router — root router for financial news data. + * Maps to: openbb_news/news_router.py + */ + +import { Router } from '../../core/app/router.js' + +export const newsRouter = new Router({ + prefix: '/news', + description: 'Financial market news data.', +}) + +newsRouter.command({ + model: 'WorldNews', + path: '/world', + description: 'Get global news data.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'WorldNews', params, credentials) + }, +}) + +newsRouter.command({ + model: 'CompanyNews', + path: '/company', + description: 'Get news for one or more companies.', + handler: async (executor, provider, params, credentials) => { + return executor.execute(provider, 'CompanyNews', params, credentials) + }, +}) diff --git a/packages/opentypebb/src/index.ts b/packages/opentypebb/src/index.ts new file mode 100644 index 00000000..591a1aa9 --- /dev/null +++ b/packages/opentypebb/src/index.ts @@ -0,0 +1,53 @@ +/** + * OpenTypeBB — Library entry point. + * + * Usage: + * import { createExecutor, createRegistry, loadAllRouters } from 'opentypebb' + * + * // Quick start — create executor and call a provider directly: + * const executor = createExecutor() + * const result = await executor.execute('fmp', 'EquityQuote', { symbol: 'AAPL' }, { fmp_api_key: '...' }) + * + * // Or use individual components: + * import { Registry, QueryExecutor, OBBject } from 'opentypebb' + */ + +// Core abstractions +export { Fetcher, type FetcherClass } from './core/provider/abstract/fetcher.js' +export { Provider, type ProviderConfig } from './core/provider/abstract/provider.js' +export { BaseQueryParamsSchema, type BaseQueryParams } from './core/provider/abstract/query-params.js' +export { BaseDataSchema, type BaseData, ForceInt } from './core/provider/abstract/data.js' + +// Registry & execution +export { Registry } from './core/provider/registry.js' +export { QueryExecutor } from './core/provider/query-executor.js' + +// App model +export { OBBject, type OBBjectData, type Warning } from './core/app/model/obbject.js' +export { type Credentials, buildCredentials } from './core/app/model/credentials.js' +export { type RequestMetadata, createMetadata } from './core/app/model/metadata.js' + +// App +export { Query, type QueryConfig } from './core/app/query.js' +export { CommandRunner } from './core/app/command-runner.js' +export { Router, type CommandDef, type CommandHandler } from './core/app/router.js' + +// Utilities +export { amakeRequest, applyAliases, replaceEmptyStrings, buildQueryString } from './core/provider/utils/helpers.js' +export { OpenBBError, EmptyDataError, UnauthorizedError } from './core/provider/utils/errors.js' + +// App loader — convenience functions to create a fully-loaded system +export { createRegistry, createExecutor, loadAllRouters } from './core/api/app-loader.js' + +// Pre-built providers (for direct import if needed) +export { fmpProvider } from './providers/fmp/index.js' +export { yfinanceProvider } from './providers/yfinance/index.js' +export { deribitProvider } from './providers/deribit/index.js' +export { cboeProvider } from './providers/cboe/index.js' +export { multplProvider } from './providers/multpl/index.js' +export { oecdProvider } from './providers/oecd/index.js' +export { econdbProvider } from './providers/econdb/index.js' +export { imfProvider } from './providers/imf/index.js' +export { ecbProvider } from './providers/ecb/index.js' +export { federalReserveProvider } from './providers/federal_reserve/index.js' +export { intrinioProvider } from './providers/intrinio/index.js' diff --git a/packages/opentypebb/src/providers/bls/index.ts b/packages/opentypebb/src/providers/bls/index.ts new file mode 100644 index 00000000..47477994 --- /dev/null +++ b/packages/opentypebb/src/providers/bls/index.ts @@ -0,0 +1,18 @@ +/** + * BLS (Bureau of Labor Statistics) Provider Module. + */ + +import { Provider } from '../../core/provider/abstract/provider.js' +import { BLSBlsSeriesFetcher } from './models/bls-series.js' +import { BLSBlsSearchFetcher } from './models/bls-search.js' + +export const blsProvider = new Provider({ + name: 'bls', + website: 'https://www.bls.gov', + description: 'Bureau of Labor Statistics — US labor market and price data.', + credentials: ['api_key'], + fetcherDict: { + BlsSeries: BLSBlsSeriesFetcher, + BlsSearch: BLSBlsSearchFetcher, + }, +}) diff --git a/packages/opentypebb/src/providers/bls/models/bls-search.ts b/packages/opentypebb/src/providers/bls/models/bls-search.ts new file mode 100644 index 00000000..99d19fc3 --- /dev/null +++ b/packages/opentypebb/src/providers/bls/models/bls-search.ts @@ -0,0 +1,66 @@ +/** + * BLS Search Fetcher. + * BLS doesn't have a search API, so we provide a curated list of common series. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { BlsSearchQueryParamsSchema, BlsSearchDataSchema } from '../../../standard-models/bls-search.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' + +export const BLSBlsSearchQueryParamsSchema = BlsSearchQueryParamsSchema +export type BLSBlsSearchQueryParams = z.infer + +// Curated list of commonly used BLS series +const COMMON_SERIES = [ + { series_id: 'CUUR0000SA0', title: 'CPI-U All Items (Urban Consumers)', survey_abbreviation: 'CU' }, + { series_id: 'CUUR0000SA0L1E', title: 'CPI-U Core (Less Food and Energy)', survey_abbreviation: 'CU' }, + { series_id: 'LNS14000000', title: 'Unemployment Rate (Seasonally Adjusted)', survey_abbreviation: 'LN' }, + { series_id: 'CES0000000001', title: 'Total Nonfarm Payrolls', survey_abbreviation: 'CE' }, + { series_id: 'CES0500000003', title: 'Average Hourly Earnings (Private)', survey_abbreviation: 'CE' }, + { series_id: 'LNS11300000', title: 'Labor Force Participation Rate', survey_abbreviation: 'LN' }, + { series_id: 'CUSR0000SAF11', title: 'CPI Food at Home', survey_abbreviation: 'CU' }, + { series_id: 'CUUR0000SETB01', title: 'CPI Gasoline', survey_abbreviation: 'CU' }, + { series_id: 'CUUR0000SETA01', title: 'CPI New Vehicles', survey_abbreviation: 'CU' }, + { series_id: 'CUUR0000SEHA', title: 'CPI Rent of Primary Residence', survey_abbreviation: 'CU' }, + { series_id: 'JTS000000000000000JOR', title: 'JOLTS Job Openings Rate', survey_abbreviation: 'JT' }, + { series_id: 'JTS000000000000000QUR', title: 'JOLTS Quits Rate', survey_abbreviation: 'JT' }, + { series_id: 'WPUFD49104', title: 'PPI Final Demand', survey_abbreviation: 'WP' }, + { series_id: 'WPUFD49116', title: 'PPI Final Demand Less Food Energy Trade', survey_abbreviation: 'WP' }, + { series_id: 'CES0500000008', title: 'Average Weekly Hours (Private)', survey_abbreviation: 'CE' }, + { series_id: 'LNS12032194', title: 'Employment-Population Ratio', survey_abbreviation: 'LN' }, + { series_id: 'LNS13327709', title: 'U-6 Unemployment Rate', survey_abbreviation: 'LN' }, + { series_id: 'PRS85006092', title: 'Nonfarm Business Labor Productivity', survey_abbreviation: 'PR' }, + { series_id: 'EIUIR', title: 'Import Price Index', survey_abbreviation: 'EI' }, + { series_id: 'EIUXR', title: 'Export Price Index', survey_abbreviation: 'EI' }, +] + +export class BLSBlsSearchFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): BLSBlsSearchQueryParams { + return BLSBlsSearchQueryParamsSchema.parse(params) + } + + static override async extractData( + query: BLSBlsSearchQueryParams, + _credentials: Record | null, + ): Promise[]> { + const q = query.query.toLowerCase() + const results = COMMON_SERIES.filter(s => + s.title.toLowerCase().includes(q) || + s.series_id.toLowerCase().includes(q) || + s.survey_abbreviation.toLowerCase().includes(q), + ).slice(0, query.limit) + + if (results.length === 0) throw new EmptyDataError(`No BLS series matching "${query.query}" found.`) + return results + } + + static override transformData( + _query: BLSBlsSearchQueryParams, + data: Record[], + ) { + return data.map(d => BlsSearchDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/bls/models/bls-series.ts b/packages/opentypebb/src/providers/bls/models/bls-series.ts new file mode 100644 index 00000000..00393b9a --- /dev/null +++ b/packages/opentypebb/src/providers/bls/models/bls-series.ts @@ -0,0 +1,90 @@ +/** + * BLS Series Fetcher. + * Uses BLS Public Data API v2. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { BlsSeriesQueryParamsSchema, BlsSeriesDataSchema } from '../../../standard-models/bls-series.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' + +export const BLSBlsSeriesQueryParamsSchema = BlsSeriesQueryParamsSchema +export type BLSBlsSeriesQueryParams = z.infer + +const BLS_API_URL = 'https://api.bls.gov/publicAPI/v2/timeseries/data/' + +interface BlsApiResponse { + status: string + Results?: { + series?: Array<{ + seriesID: string + data: Array<{ + year: string + period: string + value: string + periodName: string + }> + }> + } +} + +export class BLSBlsSeriesFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): BLSBlsSeriesQueryParams { + return BLSBlsSeriesQueryParamsSchema.parse(params) + } + + static override async extractData( + query: BLSBlsSeriesQueryParams, + credentials: Record | null, + ): Promise[]> { + const seriesIds = query.symbol.split(',').map(s => s.trim()).filter(Boolean) + const apiKey = credentials?.bls_api_key ?? '' + + const startYear = query.start_date ? query.start_date.slice(0, 4) : String(new Date().getFullYear() - 10) + const endYear = query.end_date ? query.end_date.slice(0, 4) : String(new Date().getFullYear()) + + const body: Record = { + seriesid: seriesIds, + startyear: startYear, + endyear: endYear, + } + if (apiKey) body.registrationkey = apiKey + + const data = await amakeRequest(BLS_API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + const results: Record[] = [] + for (const series of data.Results?.series ?? []) { + for (const obs of series.data) { + // Convert period M01..M12 to month + const monthMatch = obs.period.match(/M(\d{2})/) + const month = monthMatch ? monthMatch[1] : '01' + const date = `${obs.year}-${month}-01` + results.push({ + date, + series_id: series.seriesID, + value: parseFloat(obs.value), + period: obs.period, + }) + } + } + + if (results.length === 0) throw new EmptyDataError('No BLS series data found.') + return results + } + + static override transformData( + _query: BLSBlsSeriesQueryParams, + data: Record[], + ) { + return data + .sort((a, b) => String(a.date).localeCompare(String(b.date))) + .map(d => BlsSeriesDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/cboe/index.ts b/packages/opentypebb/src/providers/cboe/index.ts new file mode 100644 index 00000000..519b0193 --- /dev/null +++ b/packages/opentypebb/src/providers/cboe/index.ts @@ -0,0 +1,22 @@ +/** + * CBOE Provider Module. + * Maps to: openbb_platform/providers/cboe/openbb_cboe/__init__.py + * + * We only implement IndexSearch here. The full CBOE provider in Python + * has 11 endpoints, but we only need the missing ones. + */ + +import { Provider } from '../../core/provider/abstract/provider.js' +import { CboeIndexSearchFetcher } from './models/index-search.js' + +export const cboeProvider = new Provider({ + name: 'cboe', + website: 'https://www.cboe.com', + description: + 'Cboe is the world\'s go-to derivatives and exchange network, ' + + 'delivering cutting-edge trading, clearing and investment solutions.', + reprName: 'Chicago Board Options Exchange (CBOE)', + fetcherDict: { + IndexSearch: CboeIndexSearchFetcher, + }, +}) diff --git a/packages/opentypebb/src/providers/cboe/models/index-search.ts b/packages/opentypebb/src/providers/cboe/models/index-search.ts new file mode 100644 index 00000000..b95d2e40 --- /dev/null +++ b/packages/opentypebb/src/providers/cboe/models/index-search.ts @@ -0,0 +1,118 @@ +/** + * CBOE Index Search Model. + * Maps to: openbb_cboe/models/index_search.py + * + * Fetches the CBOE index directory and filters by query. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { IndexSearchDataSchema } from '../../../standard-models/index-search.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { nativeFetch } from '../../../core/provider/utils/helpers.js' + +const CBOE_INDEX_DIRECTORY_URL = 'https://www.cboe.com/us/indices/index-directory/' + +export const CboeIndexSearchQueryParamsSchema = z.object({ + query: z.string().default('').describe('Search query.'), + is_symbol: z.boolean().default(false).describe('Whether to search by ticker symbol.'), +}).passthrough() + +export type CboeIndexSearchQueryParams = z.infer + +export const CboeIndexSearchDataSchema = IndexSearchDataSchema.extend({ + description: z.string().nullable().default(null).describe('Description for the index.'), + currency: z.string().nullable().default(null).describe('Currency for the index.'), + time_zone: z.string().nullable().default(null).describe('Time zone for the index.'), +}).passthrough() + +export type CboeIndexSearchData = z.infer + +// Cache for the CBOE index directory +let _cachedDirectory: Record[] | null = null +let _cacheTime = 0 +const CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours + +async function getIndexDirectory(): Promise[]> { + const now = Date.now() + if (_cachedDirectory && (now - _cacheTime) < CACHE_TTL) { + return _cachedDirectory + } + + // CBOE provides index directory as JSON from their API + const url = 'https://www.cboe.com/us/indices/api/index-directory/' + try { + const resp = await nativeFetch(url, { timeoutMs: 30000 }) + if (resp.status === 200) { + const data = JSON.parse(resp.text) as Record[] + if (Array.isArray(data) && data.length > 0) { + _cachedDirectory = data + _cacheTime = now + return data + } + } + } catch { /* fall through */ } + + // Fallback: try the main endpoint with a different format + try { + const resp = await nativeFetch('https://cdn.cboe.com/api/global/us_indices/definitions/all_indices.json', { timeoutMs: 30000 }) + if (resp.status === 200) { + const json = JSON.parse(resp.text) as { data?: Record[] } + const records = json.data ?? (Array.isArray(json) ? json : []) + if (Array.isArray(records) && records.length > 0) { + _cachedDirectory = records as Record[] + _cacheTime = now + return _cachedDirectory + } + } + } catch { /* fall through */ } + + throw new EmptyDataError('Failed to fetch CBOE index directory.') +} + +export class CboeIndexSearchFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): CboeIndexSearchQueryParams { + return CboeIndexSearchQueryParamsSchema.parse(params) + } + + static override async extractData( + query: CboeIndexSearchQueryParams, + _credentials: Record | null, + ): Promise[]> { + const directory = await getIndexDirectory() + + if (!query.query) return directory + + const q = query.query.toLowerCase() + + if (query.is_symbol) { + return directory.filter(d => { + const sym = String(d.index_symbol ?? d.symbol ?? '').toLowerCase() + return sym.includes(q) + }) + } + + return directory.filter(d => { + const sym = String(d.index_symbol ?? d.symbol ?? '').toLowerCase() + const name = String(d.name ?? '').toLowerCase() + const desc = String(d.description ?? '').toLowerCase() + return sym.includes(q) || name.includes(q) || desc.includes(q) + }) + } + + static override transformData( + _query: CboeIndexSearchQueryParams, + data: Record[], + ): CboeIndexSearchData[] { + if (data.length === 0) throw new EmptyDataError('No matching indices found.') + return data.map(d => CboeIndexSearchDataSchema.parse({ + symbol: d.index_symbol ?? d.symbol ?? '', + name: d.name ?? '', + description: d.description ?? null, + currency: d.currency ?? null, + time_zone: d.time_zone ?? null, + })) + } +} diff --git a/packages/opentypebb/src/providers/deribit/index.ts b/packages/opentypebb/src/providers/deribit/index.ts new file mode 100644 index 00000000..82087d8c --- /dev/null +++ b/packages/opentypebb/src/providers/deribit/index.ts @@ -0,0 +1,26 @@ +/** + * Deribit Provider Module. + * Maps to: openbb_platform/providers/deribit/openbb_deribit/__init__.py + * + * Deribit provides free crypto derivatives data (futures & options). + * No credentials required. + */ + +import { Provider } from '../../core/provider/abstract/provider.js' +import { DeribitFuturesCurveFetcher } from './models/futures-curve.js' +import { DeribitFuturesInfoFetcher } from './models/futures-info.js' +import { DeribitFuturesInstrumentsFetcher } from './models/futures-instruments.js' +import { DeribitOptionsChainsFetcher } from './models/options-chains.js' + +export const deribitProvider = new Provider({ + name: 'deribit', + website: 'https://www.deribit.com', + description: + 'Unofficial Python client for Deribit public data. Not intended for trading.', + fetcherDict: { + FuturesCurve: DeribitFuturesCurveFetcher, + FuturesInfo: DeribitFuturesInfoFetcher, + FuturesInstruments: DeribitFuturesInstrumentsFetcher, + OptionsChains: DeribitOptionsChainsFetcher, + }, +}) diff --git a/packages/opentypebb/src/providers/deribit/models/futures-curve.ts b/packages/opentypebb/src/providers/deribit/models/futures-curve.ts new file mode 100644 index 00000000..e326ef9e --- /dev/null +++ b/packages/opentypebb/src/providers/deribit/models/futures-curve.ts @@ -0,0 +1,84 @@ +/** + * Deribit Futures Curve Model. + * Maps to: openbb_deribit/models/futures_curve.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { FuturesCurveDataSchema } from '../../../standard-models/futures-curve.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getFuturesCurveSymbols, getTickerData, DERIBIT_FUTURES_CURVE_SYMBOLS } from '../utils/helpers.js' + +export const DeribitFuturesCurveQueryParamsSchema = z.object({ + symbol: z.string().default('BTC').transform(v => v.toUpperCase()).describe('Symbol: BTC, ETH, or PAXG.'), + date: z.string().nullable().default(null).describe('Not used for Deribit. Use hours_ago instead.'), +}).passthrough() + +export type DeribitFuturesCurveQueryParams = z.infer + +export const DeribitFuturesCurveDataSchema = FuturesCurveDataSchema +export type DeribitFuturesCurveData = z.infer + +export class DeribitFuturesCurveFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): DeribitFuturesCurveQueryParams { + return DeribitFuturesCurveQueryParamsSchema.parse(params) + } + + static override async extractData( + query: DeribitFuturesCurveQueryParams, + _credentials: Record | null, + ): Promise[]> { + const symbol = query.symbol + if (!DERIBIT_FUTURES_CURVE_SYMBOLS.includes(symbol)) { + throw new Error(`Invalid symbol: ${symbol}. Valid: ${DERIBIT_FUTURES_CURVE_SYMBOLS.join(', ')}`) + } + + const instrumentNames = await getFuturesCurveSymbols(symbol) + if (instrumentNames.length === 0) throw new EmptyDataError('No instruments found.') + + const results: Record[] = [] + const tasks = instrumentNames.map(async (name) => { + try { + const ticker = await getTickerData(name) + return { instrument_name: name, ...ticker } + } catch { + return null + } + }) + const tickerResults = await Promise.all(tasks) + for (const t of tickerResults) { + if (t) results.push(t) + } + + if (results.length === 0) throw new EmptyDataError('No ticker data found.') + return results + } + + static override transformData( + _query: DeribitFuturesCurveQueryParams, + data: Record[], + ): DeribitFuturesCurveData[] { + const today = new Date().toISOString().slice(0, 10) + + return data.map(d => { + const name = d.instrument_name as string + const parts = name.split('-') + let expiration = parts[1] ?? 'PERPETUAL' + + // Parse Deribit date format (e.g., "28MAR25") to ISO + if (expiration === 'PERPETUAL') { + expiration = today + } + + const price = (d.last_price as number) ?? (d.mark_price as number) ?? 0 + + return FuturesCurveDataSchema.parse({ + date: today, + expiration, + price, + }) + }).sort((a, b) => a.expiration.localeCompare(b.expiration)) + } +} diff --git a/packages/opentypebb/src/providers/deribit/models/futures-info.ts b/packages/opentypebb/src/providers/deribit/models/futures-info.ts new file mode 100644 index 00000000..9e135286 --- /dev/null +++ b/packages/opentypebb/src/providers/deribit/models/futures-info.ts @@ -0,0 +1,97 @@ +/** + * Deribit Futures Info Model. + * Maps to: openbb_deribit/models/futures_info.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EmptyDataError, OpenBBError } from '../../../core/provider/utils/errors.js' +import { getTickerData, getFuturesSymbols, getPerpetualSymbols } from '../utils/helpers.js' + +export const DeribitFuturesInfoQueryParamsSchema = z.object({ + symbol: z.string().describe('Deribit futures instrument symbol(s), comma-separated.'), +}).passthrough() + +export type DeribitFuturesInfoQueryParams = z.infer + +export const DeribitFuturesInfoDataSchema = z.object({ + symbol: z.string().describe('Instrument name.'), + state: z.string().describe('The state of the order book.'), + open_interest: z.number().describe('Total outstanding contracts.'), + index_price: z.number().describe('Current index price.'), + best_ask_price: z.number().nullable().default(null).describe('Best ask price.'), + best_ask_amount: z.number().nullable().default(null).describe('Best ask amount.'), + best_bid_price: z.number().nullable().default(null).describe('Best bid price.'), + best_bid_amount: z.number().nullable().default(null).describe('Best bid amount.'), + last_price: z.number().nullable().default(null).describe('Last trade price.'), + high: z.number().nullable().default(null).describe('Highest price during 24h.'), + low: z.number().nullable().default(null).describe('Lowest price during 24h.'), + change_percent: z.number().nullable().default(null).describe('24-hour price change percent.'), + volume: z.number().nullable().default(null).describe('Volume during last 24h in base currency.'), + volume_usd: z.number().nullable().default(null).describe('Volume in USD.'), + mark_price: z.number().describe('Mark price for the instrument.'), + settlement_price: z.number().nullable().default(null).describe('Settlement price.'), + delivery_price: z.number().nullable().default(null).describe('Delivery price (closed instruments).'), + estimated_delivery_price: z.number().nullable().default(null).describe('Estimated delivery price.'), + current_funding: z.number().nullable().default(null).describe('Current funding (perpetual only).'), + funding_8h: z.number().nullable().default(null).describe('Funding 8h (perpetual only).'), + max_price: z.number().nullable().default(null).describe('Maximum order price.'), + min_price: z.number().nullable().default(null).describe('Minimum order price.'), + timestamp: z.number().nullable().default(null).describe('Timestamp of the data.'), +}).passthrough() + +export type DeribitFuturesInfoData = z.infer + +export class DeribitFuturesInfoFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): DeribitFuturesInfoQueryParams { + return DeribitFuturesInfoQueryParamsSchema.parse(params) + } + + static override async extractData( + query: DeribitFuturesInfoQueryParams, + _credentials: Record | null, + ): Promise[]> { + const symbols = query.symbol.split(',') + const perpetualSymbols = await getPerpetualSymbols() + const futuresSymbols = await getFuturesSymbols() + const allSymbols = [...futuresSymbols, ...Object.keys(perpetualSymbols)] + + const resolvedSymbols = symbols.map(s => { + if (perpetualSymbols[s]) return perpetualSymbols[s] + if (allSymbols.includes(s)) return s + throw new OpenBBError(`Invalid symbol: ${s}`) + }) + + const results: Record[] = [] + const tasks = resolvedSymbols.map(async (sym) => { + try { + return await getTickerData(sym) + } catch { + return null + } + }) + const tickerResults = await Promise.all(tasks) + for (const t of tickerResults) { + if (t) results.push(t) + } + + if (results.length === 0) throw new EmptyDataError('No data found.') + return results + } + + static override transformData( + _query: DeribitFuturesInfoQueryParams, + data: Record[], + ): DeribitFuturesInfoData[] { + return data.map(d => { + const priceChange = d.price_change as number | null + return DeribitFuturesInfoDataSchema.parse({ + ...d, + symbol: d.instrument_name, + change_percent: priceChange != null ? priceChange / 100 : null, + }) + }) + } +} diff --git a/packages/opentypebb/src/providers/deribit/models/futures-instruments.ts b/packages/opentypebb/src/providers/deribit/models/futures-instruments.ts new file mode 100644 index 00000000..ae4f768e --- /dev/null +++ b/packages/opentypebb/src/providers/deribit/models/futures-instruments.ts @@ -0,0 +1,70 @@ +/** + * Deribit Futures Instruments Model. + * Maps to: openbb_deribit/models/futures_instruments.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getAllFuturesInstruments } from '../utils/helpers.js' + +export const DeribitFuturesInstrumentsQueryParamsSchema = z.object({}).passthrough() + +export type DeribitFuturesInstrumentsQueryParams = z.infer + +export const DeribitFuturesInstrumentsDataSchema = z.object({ + instrument_id: z.number().describe('Deribit Instrument ID.'), + symbol: z.string().describe('Instrument name.'), + base_currency: z.string().describe('The underlying currency being traded.'), + counter_currency: z.string().describe('Counter currency for the instrument.'), + quote_currency: z.string().describe('Quote currency.'), + settlement_currency: z.string().nullable().default(null).describe('Settlement currency.'), + future_type: z.string().describe('Type: linear or reversed.'), + settlement_period: z.string().nullable().default(null).describe('The settlement period.'), + price_index: z.string().describe('Name of price index used.'), + contract_size: z.number().describe('Contract size.'), + is_active: z.boolean().describe('Whether the instrument can be traded.'), + creation_timestamp: z.number().describe('Creation timestamp (ms since epoch).'), + expiration_timestamp: z.number().nullable().default(null).describe('Expiration timestamp (ms since epoch).'), + tick_size: z.number().describe('Minimal price change.'), + min_trade_amount: z.number().describe('Minimum trading amount in USD.'), + max_leverage: z.number().describe('Maximal leverage.'), + maker_commission: z.number().nullable().default(null).describe('Maker commission.'), + taker_commission: z.number().nullable().default(null).describe('Taker commission.'), +}).passthrough() + +export type DeribitFuturesInstrumentsData = z.infer + +export class DeribitFuturesInstrumentsFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): DeribitFuturesInstrumentsQueryParams { + return DeribitFuturesInstrumentsQueryParamsSchema.parse(params) + } + + static override async extractData( + _query: DeribitFuturesInstrumentsQueryParams, + _credentials: Record | null, + ): Promise[]> { + const data = await getAllFuturesInstruments() + if (data.length === 0) throw new EmptyDataError('No instruments found.') + return data + } + + static override transformData( + _query: DeribitFuturesInstrumentsQueryParams, + data: Record[], + ): DeribitFuturesInstrumentsData[] { + return data.map(d => { + // Sentinel value for perpetual expiration + const expTs = d.expiration_timestamp as number + const expiration = expTs === 32503708800000 ? null : expTs + + return DeribitFuturesInstrumentsDataSchema.parse({ + ...d, + symbol: d.instrument_name, + expiration_timestamp: expiration, + }) + }) + } +} diff --git a/packages/opentypebb/src/providers/deribit/models/options-chains.ts b/packages/opentypebb/src/providers/deribit/models/options-chains.ts new file mode 100644 index 00000000..ab8c063b --- /dev/null +++ b/packages/opentypebb/src/providers/deribit/models/options-chains.ts @@ -0,0 +1,109 @@ +/** + * Deribit Options Chains Model. + * Maps to: openbb_deribit/models/options_chains.py + * + * Note: Python uses WebSocket connections. We use REST API for simplicity. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { OptionsChainsDataSchema } from '../../../standard-models/options-chains.js' +import { EmptyDataError, OpenBBError } from '../../../core/provider/utils/errors.js' +import { getOptionsSymbols, getTickerData, DERIBIT_OPTIONS_SYMBOLS } from '../utils/helpers.js' + +export const DeribitOptionsChainsQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol: BTC, ETH, SOL, XRP, BNB, or PAXG.'), +}).passthrough() + +export type DeribitOptionsChainsQueryParams = z.infer + +export const DeribitOptionsChainsDataSchema = OptionsChainsDataSchema +export type DeribitOptionsChainsData = z.infer + +export class DeribitOptionsChainsFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): DeribitOptionsChainsQueryParams { + return DeribitOptionsChainsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: DeribitOptionsChainsQueryParams, + _credentials: Record | null, + ): Promise[]> { + const symbol = query.symbol + if (!DERIBIT_OPTIONS_SYMBOLS.includes(symbol)) { + throw new OpenBBError(`Invalid symbol: ${symbol}. Valid: ${DERIBIT_OPTIONS_SYMBOLS.join(', ')}`) + } + + const optionsMap = await getOptionsSymbols(symbol) + if (Object.keys(optionsMap).length === 0) { + throw new EmptyDataError('No options found.') + } + + const allContracts = Object.values(optionsMap).flat() + const results: Record[] = [] + + // Fetch tickers in batches to avoid rate limiting + const batchSize = 20 + for (let i = 0; i < allContracts.length; i += batchSize) { + const batch = allContracts.slice(i, i + batchSize) + const tasks = batch.map(async (name) => { + try { + return await getTickerData(name) + } catch { + return null + } + }) + const batchResults = await Promise.all(tasks) + for (const t of batchResults) { + if (t) results.push(t) + } + } + + if (results.length === 0) throw new EmptyDataError('No options data found.') + return results + } + + static override transformData( + query: DeribitOptionsChainsQueryParams, + data: Record[], + ): DeribitOptionsChainsData[] { + return data.map(d => { + const name = d.instrument_name as string + // Parse: BTC-28MAR25-100000-C + const parts = name.split('-') + const expiration = parts[1] ?? '' + const strike = parseFloat(parts[2] ?? '0') + const optionType = parts[3] === 'C' ? 'call' : 'put' + + // Get underlying price for USD conversion + const underlyingPrice = d.underlying_price as number | null + const indexPrice = d.index_price as number | null + const refPrice = underlyingPrice ?? indexPrice ?? 1 + + return OptionsChainsDataSchema.parse({ + underlying_symbol: query.symbol, + underlying_price: refPrice, + contract_symbol: name, + expiration, + strike, + option_type: optionType, + open_interest: d.open_interest ?? null, + volume: d.stats ? (d.stats as Record).volume ?? null : null, + last_trade_price: d.last_price != null ? (d.last_price as number) * refPrice : null, + bid: d.best_bid_price != null ? (d.best_bid_price as number) * refPrice : null, + bid_size: d.best_bid_amount ?? null, + ask: d.best_ask_price != null ? (d.best_ask_price as number) * refPrice : null, + ask_size: d.best_ask_amount ?? null, + mark: d.mark_price != null ? (d.mark_price as number) * refPrice : null, + implied_volatility: d.mark_iv != null ? (d.mark_iv as number) / 100 : null, + delta: d.greeks ? (d.greeks as Record).delta ?? null : null, + gamma: d.greeks ? (d.greeks as Record).gamma ?? null : null, + theta: d.greeks ? (d.greeks as Record).theta ?? null : null, + vega: d.greeks ? (d.greeks as Record).vega ?? null : null, + rho: d.greeks ? (d.greeks as Record).rho ?? null : null, + }) + }) + } +} diff --git a/packages/opentypebb/src/providers/deribit/utils/helpers.ts b/packages/opentypebb/src/providers/deribit/utils/helpers.ts new file mode 100644 index 00000000..257ae4db --- /dev/null +++ b/packages/opentypebb/src/providers/deribit/utils/helpers.ts @@ -0,0 +1,128 @@ +/** + * Deribit Helpers Module. + * Maps to: openbb_deribit/utils/helpers.py + */ + +import { OpenBBError } from '../../../core/provider/utils/errors.js' + +export const BASE_URL = 'https://www.deribit.com' +export const DERIBIT_FUTURES_CURVE_SYMBOLS = ['BTC', 'ETH', 'PAXG'] +export const DERIBIT_OPTIONS_SYMBOLS = ['BTC', 'ETH', 'SOL', 'XRP', 'BNB', 'PAXG'] +export const CURRENCIES = ['BTC', 'ETH', 'USDC', 'USDT', 'EURR', 'all'] + +/** + * Get instruments from Deribit. + * Maps to: get_instruments() in helpers.py + */ +export async function getInstruments( + currency: string, + derivativeType: string, + expired = false, +): Promise[]> { + const url = `${BASE_URL}/api/v2/public/get_instruments?currency=${currency}&kind=${derivativeType}&expired=${expired}` + const res = await fetch(url) + if (!res.ok) throw new OpenBBError(`Deribit API error: ${res.status}`) + const json = await res.json() as Record + return (json.result ?? []) as Record[] +} + +/** + * Get all instruments for all currencies. + */ +export async function getAllFuturesInstruments(): Promise[]> { + const results: Record[] = [] + for (const currency of CURRENCIES.filter(c => c !== 'all')) { + try { + const instruments = await getInstruments(currency, 'future') + results.push(...instruments) + } catch { + // skip currencies with no futures + } + } + // Also try 'all' + try { + const allInstruments = await getInstruments('all', 'future') + // Deduplicate by instrument_name + const seen = new Set(results.map(i => i.instrument_name)) + for (const inst of allInstruments) { + if (!seen.has(inst.instrument_name)) { + results.push(inst) + } + } + } catch { /* ignore */ } + return results +} + +/** + * Get ticker data for a single instrument. + * Maps to: get_ticker_data() in helpers.py + */ +export async function getTickerData(instrumentName: string): Promise> { + const url = `${BASE_URL}/api/v2/public/ticker?instrument_name=${instrumentName}` + const res = await fetch(url) + if (!res.ok) throw new OpenBBError(`Deribit ticker error: ${res.status}`) + const json = await res.json() as Record + return (json.result ?? {}) as Record +} + +/** + * Get futures curve symbols for a given currency. + * Maps to: get_futures_curve_symbols() in helpers.py + */ +export async function getFuturesCurveSymbols(symbol: string): Promise { + const instruments = await getInstruments(symbol, 'future') + return instruments + .map(i => i.instrument_name as string) + .filter(name => name !== undefined) +} + +/** + * Get perpetual symbols mapping short names to full names. + * Maps to: get_perpetual_symbols() in helpers.py + */ +export async function getPerpetualSymbols(): Promise> { + const result: Record = {} + for (const currency of CURRENCIES.filter(c => c !== 'all')) { + try { + const instruments = await getInstruments(currency, 'future') + for (const inst of instruments) { + const name = inst.instrument_name as string + if (name?.includes('PERPETUAL')) { + const short = name.replace('-PERPETUAL', '').replace('_', '') + result[short] = name + } + } + } catch { /* skip */ } + } + return result +} + +/** + * Get all futures symbols. + * Maps to: get_futures_symbols() in helpers.py + */ +export async function getFuturesSymbols(): Promise { + const instruments = await getAllFuturesInstruments() + return instruments.map(i => i.instrument_name as string).filter(Boolean) +} + +/** + * Get options symbols grouped by expiration. + * Maps to: get_options_symbols() in helpers.py + */ +export async function getOptionsSymbols(symbol: string): Promise> { + const instruments = await getInstruments(symbol, 'option') + const result: Record = {} + for (const inst of instruments) { + const name = inst.instrument_name as string + if (!name) continue + // Parse expiration from instrument name: e.g., BTC-28MAR25-100000-C + const parts = name.split('-') + if (parts.length >= 3) { + const expiration = parts[1] + if (!result[expiration]) result[expiration] = [] + result[expiration].push(name) + } + } + return result +} diff --git a/packages/opentypebb/src/providers/ecb/index.ts b/packages/opentypebb/src/providers/ecb/index.ts new file mode 100644 index 00000000..c490336c --- /dev/null +++ b/packages/opentypebb/src/providers/ecb/index.ts @@ -0,0 +1,16 @@ +/** + * ECB Provider Module. + * Maps to: openbb_platform/providers/ecb/openbb_ecb/__init__.py + */ + +import { Provider } from '../../core/provider/abstract/provider.js' +import { ECBBalanceOfPaymentsFetcher } from './models/balance-of-payments.js' + +export const ecbProvider = new Provider({ + name: 'ecb', + website: 'https://data.ecb.europa.eu', + description: 'European Central Bank data portal.', + fetcherDict: { + BalanceOfPayments: ECBBalanceOfPaymentsFetcher, + }, +}) diff --git a/packages/opentypebb/src/providers/ecb/models/balance-of-payments.ts b/packages/opentypebb/src/providers/ecb/models/balance-of-payments.ts new file mode 100644 index 00000000..bb9edbe7 --- /dev/null +++ b/packages/opentypebb/src/providers/ecb/models/balance-of-payments.ts @@ -0,0 +1,120 @@ +/** + * ECB Balance of Payments Model. + * Maps to: openbb_ecb/models/balance_of_payments.py + * + * Uses ECB data-detail-api to fetch individual BOP series and merge by period. + * Requires proxy for network access (uses globalThis.fetch via undici). + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { BalanceOfPaymentsDataSchema } from '../../../standard-models/balance-of-payments.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' + +export const ECBBalanceOfPaymentsQueryParamsSchema = z.object({ + report_type: z.string().default('main').describe('Report type: main, summary.'), + frequency: z.enum(['monthly', 'quarterly']).default('monthly').describe('Data frequency.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type ECBBalanceOfPaymentsQueryParams = z.infer +export type ECBBalanceOfPaymentsData = z.infer + +const ECB_BASE = 'https://data.ecb.europa.eu/data-detail-api' + +type SeriesMap = Record + +function getMainSeries(freq: string): SeriesMap { + return { + current_account: `BPS.${freq}.N.I9.W1.S1.S1.T.B.CA._Z._Z._Z.EUR._T._X.N.ALL`, + goods: `BPS.${freq}.N.I9.W1.S1.S1.T.B.G._Z._Z._Z.EUR._T._X.N.ALL`, + services: `BPS.${freq}.N.I9.W1.S1.S1.T.B.S._Z._Z._Z.EUR._T._X.N.ALL`, + primary_income: `BPS.${freq}.N.I9.W1.S1.S1.T.B.IN1._Z._Z._Z.EUR._T._X.N.ALL`, + secondary_income: `BPS.${freq}.N.I9.W1.S1.S1.T.B.IN2._Z._Z._Z.EUR._T._X.N.ALL`, + capital_account: `BPS.${freq}.N.I9.W1.S1.S1.T.B.KA._Z._Z._Z.EUR._T._X.N.ALL`, + financial_account: `BPS.${freq}.N.I9.W1.S1.S1.T.N.FA._T.F._Z.EUR._T._X.N.ALL`, + } +} + +/** Fetch a single ECB series via data-detail-api */ +async function fetchSeries( + seriesId: string, + startDate: string, + endDate: string, +): Promise> { + const url = `${ECB_BASE}/${seriesId}?startPeriod=${startDate.replace(/-/g, '')}&endPeriod=${endDate.replace(/-/g, '')}` + + try { + const resp = await fetch(url, { signal: AbortSignal.timeout(30000) }) + if (!resp.ok) return [] + const data = await resp.json() as any[] + if (!Array.isArray(data)) return [] + + return data + .filter((d: any) => d?.PERIOD && (d?.OBS != null || d?.OBS_VALUE != null)) + .map((d: any) => ({ + period: String(d.PERIOD), + value: parseFloat(String(d.OBS ?? d.OBS_VALUE ?? 0)), + })) + } catch { + return [] + } +} + +export class ECBBalanceOfPaymentsFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): ECBBalanceOfPaymentsQueryParams { + return ECBBalanceOfPaymentsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: ECBBalanceOfPaymentsQueryParams, + _credentials: Record | null, + ): Promise[]> { + const freq = query.frequency === 'monthly' ? 'M' : 'Q' + const series = getMainSeries(freq) + const startDate = query.start_date ?? '2000-01-01' + const endDate = query.end_date ?? new Date().toISOString().slice(0, 10) + + // Fetch all series in parallel + const entries = Object.entries(series) + const results = await Promise.all( + entries.map(async ([fieldName, seriesId]) => { + const data = await fetchSeries(seriesId, startDate, endDate) + return { fieldName, data } + }) + ) + + // Merge by period + const periodMap: Record> = {} + for (const { fieldName, data } of results) { + for (const { period, value } of data) { + if (!periodMap[period]) { + // Format period: already "2025-12-01" from API, or "20241231" -> "2024-12-31" + let formatted = period + if (/^\d{8}$/.test(period)) { + formatted = `${period.slice(0, 4)}-${period.slice(4, 6)}-${period.slice(6, 8)}` + } + periodMap[period] = { period: formatted } + } + periodMap[period][fieldName] = value + } + } + + const rows = Object.values(periodMap) + if (!rows.length) throw new EmptyDataError('No ECB BOP data found') + return rows + } + + static override transformData( + _query: ECBBalanceOfPaymentsQueryParams, + data: Record[], + ): ECBBalanceOfPaymentsData[] { + if (data.length === 0) throw new EmptyDataError() + return data + .sort((a, b) => String(a.period).localeCompare(String(b.period))) + .map(d => BalanceOfPaymentsDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/econdb/index.ts b/packages/opentypebb/src/providers/econdb/index.ts new file mode 100644 index 00000000..4f53c550 --- /dev/null +++ b/packages/opentypebb/src/providers/econdb/index.ts @@ -0,0 +1,23 @@ +/** + * EconDB Provider Module. + * Maps to: openbb_platform/providers/econdb/openbb_econdb/__init__.py + */ + +import { Provider } from '../../core/provider/abstract/provider.js' +import { EconDBAvailableIndicatorsFetcher } from './models/available-indicators.js' +import { EconDBCountryProfileFetcher } from './models/country-profile.js' +import { EconDBExportDestinationsFetcher } from './models/export-destinations.js' +import { EconDBEconomicIndicatorsFetcher } from './models/economic-indicators.js' + +export const econdbProvider = new Provider({ + name: 'econdb', + website: 'https://www.econdb.com', + description: 'EconDB provides economic data aggregated from official sources.', + credentials: ['api_key'], + fetcherDict: { + AvailableIndicators: EconDBAvailableIndicatorsFetcher, + CountryProfile: EconDBCountryProfileFetcher, + ExportDestinations: EconDBExportDestinationsFetcher, + EconomicIndicators: EconDBEconomicIndicatorsFetcher, + }, +}) diff --git a/packages/opentypebb/src/providers/econdb/models/available-indicators.ts b/packages/opentypebb/src/providers/econdb/models/available-indicators.ts new file mode 100644 index 00000000..1ecc82c4 --- /dev/null +++ b/packages/opentypebb/src/providers/econdb/models/available-indicators.ts @@ -0,0 +1,56 @@ +/** + * EconDB Available Indicators Model. + * Maps to: openbb_econdb/models/available_indicators.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { AvailableIndicatorsDataSchema } from '../../../standard-models/available-indicators.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' + +export const EconDBAvailableIndicatorsQueryParamsSchema = z.object({}).passthrough() +export type EconDBAvailableIndicatorsQueryParams = z.infer +export type EconDBAvailableIndicatorsData = z.infer + +const ECONDB_BASE = 'https://www.econdb.com/api/series/?format=json' + +export class EconDBAvailableIndicatorsFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): EconDBAvailableIndicatorsQueryParams { + return EconDBAvailableIndicatorsQueryParamsSchema.parse(params) + } + + static override async extractData( + _query: EconDBAvailableIndicatorsQueryParams, + credentials: Record | null, + ): Promise[]> { + const token = credentials?.econdb_api_key ?? '' + const url = token ? `${ECONDB_BASE}&token=${token}` : ECONDB_BASE + + try { + const data = await amakeRequest>(url) + const results = (data.results ?? data) as Record[] + if (!Array.isArray(results) || results.length === 0) throw new EmptyDataError() + return results + } catch (err) { + if (err instanceof EmptyDataError) throw err + throw new EmptyDataError(`Failed to fetch EconDB indicators: ${err}`) + } + } + + static override transformData( + _query: EconDBAvailableIndicatorsQueryParams, + data: Record[], + ): EconDBAvailableIndicatorsData[] { + return data.map(d => AvailableIndicatorsDataSchema.parse({ + symbol_root: d.ticker ?? d.symbol_root ?? null, + symbol: d.ticker ?? d.symbol ?? null, + country: d.geography?.toString() ?? d.country ?? null, + iso: d.iso ?? null, + description: d.description ?? d.name ?? null, + frequency: d.frequency ?? null, + })) + } +} diff --git a/packages/opentypebb/src/providers/econdb/models/country-profile.ts b/packages/opentypebb/src/providers/econdb/models/country-profile.ts new file mode 100644 index 00000000..60f3cd34 --- /dev/null +++ b/packages/opentypebb/src/providers/econdb/models/country-profile.ts @@ -0,0 +1,73 @@ +/** + * EconDB Country Profile Model. + * Maps to: openbb_econdb/models/country_profile.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CountryProfileDataSchema } from '../../../standard-models/country-profile.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' + +export const EconDBCountryProfileQueryParamsSchema = z.object({ + country: z.string().transform(v => v.toLowerCase().replace(/ /g, '_')).describe('The country to get data for.'), +}).passthrough() + +export type EconDBCountryProfileQueryParams = z.infer +export type EconDBCountryProfileData = z.infer + +const COUNTRY_ISO: Record = { + united_states: 'US', united_kingdom: 'GB', japan: 'JP', germany: 'DE', + france: 'FR', italy: 'IT', canada: 'CA', australia: 'AU', + south_korea: 'KR', mexico: 'MX', brazil: 'BR', china: 'CN', + india: 'IN', turkey: 'TR', south_africa: 'ZA', russia: 'RU', + spain: 'ES', netherlands: 'NL', switzerland: 'CH', sweden: 'SE', +} + +export class EconDBCountryProfileFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): EconDBCountryProfileQueryParams { + return EconDBCountryProfileQueryParamsSchema.parse(params) + } + + static override async extractData( + query: EconDBCountryProfileQueryParams, + credentials: Record | null, + ): Promise[]> { + const iso = COUNTRY_ISO[query.country] ?? query.country.toUpperCase().slice(0, 2) + const token = credentials?.econdb_api_key ?? '' + const tokenParam = token ? `&token=${token}` : '' + const url = `https://www.econdb.com/api/country/${iso}/?format=json${tokenParam}` + + try { + const data = await amakeRequest>(url) + return [{ ...data, country: query.country }] + } catch (err) { + throw new EmptyDataError(`Failed to fetch EconDB country profile: ${err}`) + } + } + + static override transformData( + _query: EconDBCountryProfileQueryParams, + data: Record[], + ): EconDBCountryProfileData[] { + if (data.length === 0) throw new EmptyDataError() + return data.map(d => CountryProfileDataSchema.parse({ + country: d.country ?? '', + population: d.population ?? null, + gdp_usd: d.gdp ?? null, + gdp_qoq: d.gdp_qoq ?? null, + gdp_yoy: d.gdp_yoy ?? null, + cpi_yoy: d.cpi ?? null, + core_yoy: d.core_cpi ?? null, + retail_sales_yoy: d.retail_sales ?? null, + industrial_production_yoy: d.industrial_production ?? null, + policy_rate: d.interest_rate ?? null, + yield_10y: d.bond_yield_10y ?? null, + govt_debt_gdp: d.govt_debt ?? null, + current_account_gdp: d.current_account ?? null, + jobless_rate: d.unemployment ?? null, + })) + } +} diff --git a/packages/opentypebb/src/providers/econdb/models/economic-indicators.ts b/packages/opentypebb/src/providers/econdb/models/economic-indicators.ts new file mode 100644 index 00000000..8177fb8a --- /dev/null +++ b/packages/opentypebb/src/providers/econdb/models/economic-indicators.ts @@ -0,0 +1,79 @@ +/** + * EconDB Economic Indicators Model. + * Maps to: openbb_econdb/models/economic_indicators.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EconomicIndicatorsDataSchema } from '../../../standard-models/economic-indicators.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' + +export const EconDBEconomicIndicatorsQueryParamsSchema = z.object({ + symbol: z.string().describe('Indicator symbol (e.g., GDP, CPI, URATE).'), + country: z.string().nullable().default(null).describe('Country to filter by.'), + frequency: z.string().nullable().default(null).describe('Data frequency.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type EconDBEconomicIndicatorsQueryParams = z.infer +export type EconDBEconomicIndicatorsData = z.infer + +export class EconDBEconomicIndicatorsFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): EconDBEconomicIndicatorsQueryParams { + return EconDBEconomicIndicatorsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: EconDBEconomicIndicatorsQueryParams, + credentials: Record | null, + ): Promise[]> { + const token = credentials?.econdb_api_key ?? '' + const tokenParam = token ? `&token=${token}` : '' + let url = `https://www.econdb.com/api/series/${query.symbol}/?format=json${tokenParam}` + + try { + const data = await amakeRequest>(url) + const values = (data.data ?? data.results ?? []) as Record[] + + if (!Array.isArray(values) || values.length === 0) { + // Try alternative format + const dates = data.dates as string[] | undefined + const vals = data.values as number[] | undefined + if (dates && vals) { + return dates.map((d, i) => ({ + date: d, + symbol: query.symbol, + country: query.country, + value: vals[i], + })) + } + throw new EmptyDataError() + } + + return values.map(v => ({ + ...v, + symbol: query.symbol, + country: query.country ?? v.country, + })) + } catch (err) { + if (err instanceof EmptyDataError) throw err + throw new EmptyDataError(`Failed to fetch EconDB indicators: ${err}`) + } + } + + static override transformData( + query: EconDBEconomicIndicatorsQueryParams, + data: Record[], + ): EconDBEconomicIndicatorsData[] { + let filtered = data + if (query.start_date) filtered = filtered.filter(d => String(d.date) >= query.start_date!) + if (query.end_date) filtered = filtered.filter(d => String(d.date) <= query.end_date!) + return filtered + .sort((a, b) => String(a.date).localeCompare(String(b.date))) + .map(d => EconomicIndicatorsDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/econdb/models/export-destinations.ts b/packages/opentypebb/src/providers/econdb/models/export-destinations.ts new file mode 100644 index 00000000..eff2aabf --- /dev/null +++ b/packages/opentypebb/src/providers/econdb/models/export-destinations.ts @@ -0,0 +1,61 @@ +/** + * EconDB Export Destinations Model. + * Maps to: openbb_econdb/models/export_destinations.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { ExportDestinationsDataSchema } from '../../../standard-models/export-destinations.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' + +export const EconDBExportDestinationsQueryParamsSchema = z.object({ + country: z.string().describe('The country to get data for.'), +}).passthrough() + +export type EconDBExportDestinationsQueryParams = z.infer +export type EconDBExportDestinationsData = z.infer + +const COUNTRY_ISO: Record = { + united_states: 'US', united_kingdom: 'GB', japan: 'JP', germany: 'DE', + france: 'FR', italy: 'IT', canada: 'CA', china: 'CN', +} + +export class EconDBExportDestinationsFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): EconDBExportDestinationsQueryParams { + return EconDBExportDestinationsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: EconDBExportDestinationsQueryParams, + credentials: Record | null, + ): Promise[]> { + const iso = COUNTRY_ISO[query.country] ?? query.country.toUpperCase().slice(0, 2) + const token = credentials?.econdb_api_key ?? '' + const tokenParam = token ? `&token=${token}` : '' + const url = `https://www.econdb.com/api/country/${iso}/trade/?format=json${tokenParam}` + + try { + const data = await amakeRequest>(url) + const exports = (data.exports ?? data.results ?? []) as Record[] + if (!Array.isArray(exports) || exports.length === 0) throw new EmptyDataError() + return exports.map(e => ({ ...e, origin_country: query.country })) + } catch (err) { + if (err instanceof EmptyDataError) throw err + throw new EmptyDataError(`Failed to fetch export destinations: ${err}`) + } + } + + static override transformData( + _query: EconDBExportDestinationsQueryParams, + data: Record[], + ): EconDBExportDestinationsData[] { + return data.map(d => ExportDestinationsDataSchema.parse({ + origin_country: d.origin_country ?? '', + destination_country: d.partner ?? d.destination_country ?? '', + value: d.value ?? d.amount ?? 0, + })) + } +} diff --git a/packages/opentypebb/src/providers/eia/index.ts b/packages/opentypebb/src/providers/eia/index.ts new file mode 100644 index 00000000..1e1d57cb --- /dev/null +++ b/packages/opentypebb/src/providers/eia/index.ts @@ -0,0 +1,23 @@ +/** + * EIA (Energy Information Administration) Provider Module. + * Provides petroleum and energy data from the US EIA API. + */ + +import { Provider } from '../../core/provider/abstract/provider.js' + +import { EIAPetroleumStatusReportFetcher } from './models/petroleum-status-report.js' +import { EIAShortTermEnergyOutlookFetcher } from './models/short-term-energy-outlook.js' + +export const eiaProvider = new Provider({ + name: 'eia', + website: 'https://www.eia.gov', + description: + 'The U.S. Energy Information Administration (EIA) collects, analyzes, and ' + + 'disseminates independent and impartial energy information.', + credentials: ['eia_api_key'], + fetcherDict: { + PetroleumStatusReport: EIAPetroleumStatusReportFetcher, + ShortTermEnergyOutlook: EIAShortTermEnergyOutlookFetcher, + }, + reprName: 'EIA', +}) diff --git a/packages/opentypebb/src/providers/eia/models/petroleum-status-report.ts b/packages/opentypebb/src/providers/eia/models/petroleum-status-report.ts new file mode 100644 index 00000000..e898bf94 --- /dev/null +++ b/packages/opentypebb/src/providers/eia/models/petroleum-status-report.ts @@ -0,0 +1,88 @@ +/** + * EIA Petroleum Status Report Fetcher. + * Uses EIA Open Data API v2. + * API docs: https://www.eia.gov/opendata/ + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { PetroleumStatusReportQueryParamsSchema, PetroleumStatusReportDataSchema } from '../../../standard-models/petroleum-status-report.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' + +export const EIAPetroleumStatusReportQueryParamsSchema = PetroleumStatusReportQueryParamsSchema +export type EIAPetroleumStatusReportQueryParams = z.infer + +const EIA_API_URL = 'https://api.eia.gov/v2/petroleum/sum/sndw/data/' + +// Map categories to EIA series IDs +const CATEGORY_SERIES: Record = { + crude_oil_production: { series: 'WCRFPUS2', unit: 'Thousand Barrels per Day' }, + crude_oil_stocks: { series: 'WCESTUS1', unit: 'Thousand Barrels' }, + gasoline_stocks: { series: 'WGTSTUS1', unit: 'Thousand Barrels' }, + distillate_stocks: { series: 'WDISTUS1', unit: 'Thousand Barrels' }, + refinery_utilization: { series: 'WPULEUS3', unit: 'Percent' }, +} + +interface EiaResponse { + response?: { + data?: Array<{ + period: string + value: number | null + 'series-description'?: string + }> + } +} + +export class EIAPetroleumStatusReportFetcher extends Fetcher { + static override transformQuery(params: Record): EIAPetroleumStatusReportQueryParams { + return EIAPetroleumStatusReportQueryParamsSchema.parse(params) + } + + static override async extractData( + query: EIAPetroleumStatusReportQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.eia_api_key ?? credentials?.api_key ?? '' + const catInfo = CATEGORY_SERIES[query.category] + if (!catInfo) throw new EmptyDataError(`Unknown category: ${query.category}`) + + const params = new URLSearchParams({ + api_key: apiKey, + frequency: 'weekly', + 'data[0]': 'value', + 'facets[series][]': catInfo.series, + sort: JSON.stringify([{ column: 'period', direction: 'desc' }]), + length: '260', // ~5 years of weekly data + }) + + if (query.start_date) params.set('start', query.start_date) + if (query.end_date) params.set('end', query.end_date) + + const url = `${EIA_API_URL}?${params.toString()}` + const data = await amakeRequest(url) + + const results: Record[] = [] + for (const obs of data.response?.data ?? []) { + if (obs.value == null) continue + results.push({ + date: obs.period, + value: obs.value, + category: query.category, + unit: catInfo.unit, + }) + } + + if (results.length === 0) throw new EmptyDataError('No EIA petroleum data found.') + return results + } + + static override transformData( + _query: EIAPetroleumStatusReportQueryParams, + data: Record[], + ) { + return data + .sort((a, b) => String(a.date).localeCompare(String(b.date))) + .map(d => PetroleumStatusReportDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/eia/models/short-term-energy-outlook.ts b/packages/opentypebb/src/providers/eia/models/short-term-energy-outlook.ts new file mode 100644 index 00000000..be6b4291 --- /dev/null +++ b/packages/opentypebb/src/providers/eia/models/short-term-energy-outlook.ts @@ -0,0 +1,92 @@ +/** + * EIA Short-Term Energy Outlook (STEO) Fetcher. + * Uses EIA Open Data API v2. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { ShortTermEnergyOutlookQueryParamsSchema, ShortTermEnergyOutlookDataSchema } from '../../../standard-models/short-term-energy-outlook.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' + +export const EIAShortTermEnergyOutlookQueryParamsSchema = ShortTermEnergyOutlookQueryParamsSchema +export type EIAShortTermEnergyOutlookQueryParams = z.infer + +const EIA_STEO_URL = 'https://api.eia.gov/v2/steo/data/' + +// Map categories to EIA STEO series +const CATEGORY_SERIES: Record = { + crude_oil_price: { series: 'BREPUUS', unit: 'Dollars per Barrel' }, + gasoline_price: { series: 'MGWHUUS', unit: 'Dollars per Gallon' }, + natural_gas_price: { series: 'NGHHUUS', unit: 'Dollars per MMBtu' }, + crude_oil_production: { series: 'PAPRPUS', unit: 'Million Barrels per Day' }, + petroleum_consumption: { series: 'PATCPUS', unit: 'Million Barrels per Day' }, +} + +interface EiaSteoResponse { + response?: { + data?: Array<{ + period: string + value: number | null + seriesDescription?: string + }> + } +} + +export class EIAShortTermEnergyOutlookFetcher extends Fetcher { + static override transformQuery(params: Record): EIAShortTermEnergyOutlookQueryParams { + return EIAShortTermEnergyOutlookQueryParamsSchema.parse(params) + } + + static override async extractData( + query: EIAShortTermEnergyOutlookQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.eia_api_key ?? credentials?.api_key ?? '' + const catInfo = CATEGORY_SERIES[query.category] + if (!catInfo) throw new EmptyDataError(`Unknown STEO category: ${query.category}`) + + const params = new URLSearchParams({ + api_key: apiKey, + frequency: 'monthly', + 'data[0]': 'value', + 'facets[seriesId][]': catInfo.series, + sort: JSON.stringify([{ column: 'period', direction: 'desc' }]), + length: '120', // ~10 years of monthly data + }) + + if (query.start_date) params.set('start', query.start_date.slice(0, 7)) // YYYY-MM + if (query.end_date) params.set('end', query.end_date.slice(0, 7)) + + const url = `${EIA_STEO_URL}?${params.toString()}` + const data = await amakeRequest(url) + + // Determine current date to flag forecasts + const now = new Date() + const currentPeriod = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` + + const results: Record[] = [] + for (const obs of data.response?.data ?? []) { + if (obs.value == null) continue + results.push({ + date: `${obs.period}-01`, + value: obs.value, + category: query.category, + unit: catInfo.unit, + forecast: obs.period > currentPeriod, + }) + } + + if (results.length === 0) throw new EmptyDataError('No EIA STEO data found.') + return results + } + + static override transformData( + _query: EIAShortTermEnergyOutlookQueryParams, + data: Record[], + ) { + return data + .sort((a, b) => String(a.date).localeCompare(String(b.date))) + .map(d => ShortTermEnergyOutlookDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/index.ts b/packages/opentypebb/src/providers/federal_reserve/index.ts new file mode 100644 index 00000000..c82bb8de --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/index.ts @@ -0,0 +1,53 @@ +/** + * Federal Reserve Provider Module. + * Maps to: openbb_platform/providers/federal_reserve/openbb_federal_reserve/__init__.py + */ + +import { Provider } from '../../core/provider/abstract/provider.js' +import { FedCentralBankHoldingsFetcher } from './models/central-bank-holdings.js' +import { FedFredSearchFetcher } from './models/fred-search.js' +import { FedFredSeriesFetcher } from './models/fred-series.js' +import { FedFredReleaseTableFetcher } from './models/fred-release-table.js' +import { FedFredRegionalFetcher } from './models/fred-regional.js' +import { FedUnemploymentFetcher } from './models/unemployment.js' +import { FedMoneyMeasuresFetcher } from './models/money-measures.js' +import { FedPCEFetcher } from './models/pce.js' +import { FedTotalFactorProductivityFetcher } from './models/total-factor-productivity.js' +import { FedFomcDocumentsFetcher } from './models/fomc-documents.js' +import { FedPrimaryDealerPositioningFetcher } from './models/primary-dealer-positioning.js' +import { FedPrimaryDealerFailsFetcher } from './models/primary-dealer-fails.js' +import { FedNonfarmPayrollsFetcher } from './models/nonfarm-payrolls.js' +import { FedInflationExpectationsFetcher } from './models/inflation-expectations.js' +import { FedSloosFetcher } from './models/sloos.js' +import { FedUniversityOfMichiganFetcher } from './models/university-of-michigan.js' +import { FedEconomicConditionsChicagoFetcher } from './models/economic-conditions-chicago.js' +import { FedManufacturingOutlookNYFetcher } from './models/manufacturing-outlook-ny.js' +import { FedManufacturingOutlookTexasFetcher } from './models/manufacturing-outlook-texas.js' + +export const federalReserveProvider = new Provider({ + name: 'federal_reserve', + website: 'https://www.federalreserve.gov', + description: 'Federal Reserve Economic Data.', + credentials: ['api_key'], + fetcherDict: { + CentralBankHoldings: FedCentralBankHoldingsFetcher, + FredSearch: FedFredSearchFetcher, + FredSeries: FedFredSeriesFetcher, + FredReleaseTable: FedFredReleaseTableFetcher, + FredRegional: FedFredRegionalFetcher, + Unemployment: FedUnemploymentFetcher, + MoneyMeasures: FedMoneyMeasuresFetcher, + PersonalConsumptionExpenditures: FedPCEFetcher, + TotalFactorProductivity: FedTotalFactorProductivityFetcher, + FomcDocuments: FedFomcDocumentsFetcher, + PrimaryDealerPositioning: FedPrimaryDealerPositioningFetcher, + PrimaryDealerFails: FedPrimaryDealerFailsFetcher, + NonfarmPayrolls: FedNonfarmPayrollsFetcher, + InflationExpectations: FedInflationExpectationsFetcher, + Sloos: FedSloosFetcher, + UniversityOfMichigan: FedUniversityOfMichiganFetcher, + EconomicConditionsChicago: FedEconomicConditionsChicagoFetcher, + ManufacturingOutlookNY: FedManufacturingOutlookNYFetcher, + ManufacturingOutlookTexas: FedManufacturingOutlookTexasFetcher, + }, +}) diff --git a/packages/opentypebb/src/providers/federal_reserve/models/central-bank-holdings.ts b/packages/opentypebb/src/providers/federal_reserve/models/central-bank-holdings.ts new file mode 100644 index 00000000..7e5e4e54 --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/central-bank-holdings.ts @@ -0,0 +1,88 @@ +/** + * Federal Reserve Central Bank Holdings Model. + * Maps to: openbb_federal_reserve/models/central_bank_holdings.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CentralBankHoldingsDataSchema } from '../../../standard-models/central-bank-holdings.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' + +export const FedCentralBankHoldingsQueryParamsSchema = z.object({ + date: z.string().nullable().default(null).describe('Specific date for holdings data in YYYY-MM-DD.'), +}).passthrough() + +export type FedCentralBankHoldingsQueryParams = z.infer + +export const FedCentralBankHoldingsDataSchema = CentralBankHoldingsDataSchema.extend({ + treasury_holding_value: z.number().nullable().default(null).describe('Treasury securities held (millions USD).'), + mbs_holding_value: z.number().nullable().default(null).describe('MBS held (millions USD).'), + agency_holding_value: z.number().nullable().default(null).describe('Agency debt held (millions USD).'), + total_assets: z.number().nullable().default(null).describe('Total assets (millions USD).'), +}).passthrough() + +export type FedCentralBankHoldingsData = z.infer + +// FRED series for Fed balance sheet +const FRED_BASE = 'https://api.stlouisfed.org/fred/series/observations' + +export class FedCentralBankHoldingsFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedCentralBankHoldingsQueryParams { + return FedCentralBankHoldingsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedCentralBankHoldingsQueryParams, + credentials: Record | null, + ): Promise[]> { + const fredKey = credentials?.fred_api_key ?? '' + + // Fed H.4.1 data is available from FRED + // TREAST = Treasury securities held, MBST = MBS held, WSHOMCB = Total assets + const series = ['TREAST', 'MBST', 'WSHOMCB'] + const dateParam = query.date ? `&observation_start=${query.date}&observation_end=${query.date}` : '' + const apiKeyParam = fredKey ? `&api_key=${fredKey}` : '' + + const dataMap: Record> = {} + + for (const seriesId of series) { + try { + const url = `${FRED_BASE}?series_id=${seriesId}&file_type=json&sort_order=desc&limit=100${dateParam}${apiKeyParam}` + const data = await amakeRequest>(url) + const observations = (data.observations ?? []) as Array<{ date: string; value: string }> + + for (const obs of observations) { + const val = parseFloat(obs.value) + if (!isNaN(val)) { + if (!dataMap[obs.date]) dataMap[obs.date] = {} + dataMap[obs.date][seriesId] = val + } + } + } catch { + // Skip series that fail + } + } + + const results = Object.entries(dataMap).map(([date, values]) => ({ + date, + treasury_holding_value: values.TREAST ?? null, + mbs_holding_value: values.MBST ?? null, + total_assets: values.WSHOMCB ?? null, + })) + + if (results.length === 0) throw new EmptyDataError('No Fed holdings data found.') + return results + } + + static override transformData( + _query: FedCentralBankHoldingsQueryParams, + data: Record[], + ): FedCentralBankHoldingsData[] { + return data + .sort((a, b) => String(a.date).localeCompare(String(b.date))) + .map(d => FedCentralBankHoldingsDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/models/economic-conditions-chicago.ts b/packages/opentypebb/src/providers/federal_reserve/models/economic-conditions-chicago.ts new file mode 100644 index 00000000..417714ba --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/economic-conditions-chicago.ts @@ -0,0 +1,49 @@ +/** + * Federal Reserve Chicago Fed National Activity Index Fetcher. + * Uses FRED series: CFNAI (CFNAI), CFNAIMA3 (3-month moving average). + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EconomicConditionsChicagoQueryParamsSchema, EconomicConditionsChicagoDataSchema } from '../../../standard-models/economic-conditions-chicago.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchFredMultiSeries, multiSeriesToRecords, getFredApiKey } from '../utils/fred-helpers.js' + +export const FedChicagoQueryParamsSchema = EconomicConditionsChicagoQueryParamsSchema +export type FedChicagoQueryParams = z.infer + +const SERIES = ['CFNAI', 'CFNAIMA3'] +const FIELD_MAP: Record = { + CFNAI: 'cfnai', + CFNAIMA3: 'cfnai_ma3', +} + +export class FedEconomicConditionsChicagoFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedChicagoQueryParams { + return FedChicagoQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedChicagoQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = getFredApiKey(credentials) + const dataMap = await fetchFredMultiSeries(SERIES, apiKey, { + startDate: query.start_date, + endDate: query.end_date, + }) + + const records = multiSeriesToRecords(dataMap, FIELD_MAP) + if (records.length === 0) throw new EmptyDataError('No Chicago Fed data found.') + return records + } + + static override transformData( + _query: FedChicagoQueryParams, + data: Record[], + ) { + return data.map(d => EconomicConditionsChicagoDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/models/fomc-documents.ts b/packages/opentypebb/src/providers/federal_reserve/models/fomc-documents.ts new file mode 100644 index 00000000..b9d886c2 --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/fomc-documents.ts @@ -0,0 +1,68 @@ +/** + * Federal Reserve FOMC Documents Fetcher. + * Fetches FOMC calendar and document links from the Fed website JSON API. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { FomcDocumentsQueryParamsSchema, FomcDocumentsDataSchema } from '../../../standard-models/fomc-documents.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' + +export const FedFomcDocumentsQueryParamsSchema = FomcDocumentsQueryParamsSchema +export type FedFomcDocumentsQueryParams = z.infer + +const FOMC_CALENDAR_URL = 'https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm' + +interface FomcMeeting { + date: string + link?: string + statement?: string + minutes?: string +} + +export class FedFomcDocumentsFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedFomcDocumentsQueryParams { + return FedFomcDocumentsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedFomcDocumentsQueryParams, + _credentials: Record | null, + ): Promise[]> { + // Use FRED series for Fed Funds Rate as a proxy for FOMC activity + // FRED series DFEDTAR (target rate) and DFEDTARU (upper bound) + try { + const data = await amakeRequest>( + 'https://api.stlouisfed.org/fred/series/observations?series_id=DFEDTARU&file_type=json&sort_order=desc&limit=50', + ) + const observations = (data.observations ?? []) as Array<{ date: string; value: string }> + if (observations.length === 0) throw new EmptyDataError() + + return observations + .filter(o => o.value !== '.') + .map(o => ({ + date: o.date, + title: `Fed Funds Target Rate Upper Bound: ${o.value}%`, + type: 'rate_decision', + url: FOMC_CALENDAR_URL, + })) + } catch { + throw new EmptyDataError('No FOMC documents data found.') + } + } + + static override transformData( + query: FedFomcDocumentsQueryParams, + data: Record[], + ) { + let filtered = data + if (query.start_date) filtered = filtered.filter(d => String(d.date) >= query.start_date!) + if (query.end_date) filtered = filtered.filter(d => String(d.date) <= query.end_date!) + return filtered + .sort((a, b) => String(a.date).localeCompare(String(b.date))) + .map(d => FomcDocumentsDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/models/fred-regional.ts b/packages/opentypebb/src/providers/federal_reserve/models/fred-regional.ts new file mode 100644 index 00000000..89654040 --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/fred-regional.ts @@ -0,0 +1,42 @@ +/** + * Federal Reserve FRED Regional (GeoFRED) Fetcher. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { FredRegionalQueryParamsSchema, FredRegionalDataSchema } from '../../../standard-models/fred-regional.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fredRegionalApi, getFredApiKey } from '../utils/fred-helpers.js' + +export const FedFredRegionalQueryParamsSchema = FredRegionalQueryParamsSchema +export type FedFredRegionalQueryParams = z.infer + +export class FedFredRegionalFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedFredRegionalQueryParams { + return FedFredRegionalQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedFredRegionalQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = getFredApiKey(credentials) + const results = await fredRegionalApi(query.symbol, apiKey, { + regionType: query.region_type, + date: query.date ?? undefined, + startDate: query.start_date ?? undefined, + frequency: query.frequency ?? undefined, + }) + if (results.length === 0) throw new EmptyDataError('No GeoFRED data found.') + return results + } + + static override transformData( + _query: FedFredRegionalQueryParams, + data: Record[], + ) { + return data.map(d => FredRegionalDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/models/fred-release-table.ts b/packages/opentypebb/src/providers/federal_reserve/models/fred-release-table.ts new file mode 100644 index 00000000..e1e4d0cc --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/fred-release-table.ts @@ -0,0 +1,40 @@ +/** + * Federal Reserve FRED Release Table Fetcher. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { FredReleaseTableQueryParamsSchema, FredReleaseTableDataSchema } from '../../../standard-models/fred-release-table.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fredReleaseTableApi, getFredApiKey } from '../utils/fred-helpers.js' + +export const FedFredReleaseTableQueryParamsSchema = FredReleaseTableQueryParamsSchema +export type FedFredReleaseTableQueryParams = z.infer + +export class FedFredReleaseTableFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedFredReleaseTableQueryParams { + return FedFredReleaseTableQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedFredReleaseTableQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = getFredApiKey(credentials) + const results = await fredReleaseTableApi(query.release_id, apiKey, { + elementId: query.element_id ?? undefined, + date: query.date ?? undefined, + }) + if (results.length === 0) throw new EmptyDataError('No release table data found.') + return results + } + + static override transformData( + _query: FedFredReleaseTableQueryParams, + data: Record[], + ) { + return data.map(d => FredReleaseTableDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/models/fred-search.ts b/packages/opentypebb/src/providers/federal_reserve/models/fred-search.ts new file mode 100644 index 00000000..deaee26d --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/fred-search.ts @@ -0,0 +1,45 @@ +/** + * Federal Reserve FRED Search Fetcher. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { FredSearchQueryParamsSchema, FredSearchDataSchema } from '../../../standard-models/fred-search.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fredSearchApi, getFredApiKey } from '../utils/fred-helpers.js' + +export const FedFredSearchQueryParamsSchema = FredSearchQueryParamsSchema +export type FedFredSearchQueryParams = z.infer + +export class FedFredSearchFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedFredSearchQueryParams { + return FedFredSearchQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedFredSearchQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = getFredApiKey(credentials) + const results = await fredSearchApi(query.query, apiKey, { limit: query.limit }) + if (results.length === 0) throw new EmptyDataError('No FRED series found.') + return results.map(r => ({ + series_id: r.id, + title: r.title, + frequency: r.frequency_short || null, + units: r.units_short || null, + seasonal_adjustment: r.seasonal_adjustment_short || null, + last_updated: r.last_updated || null, + notes: r.notes || null, + })) + } + + static override transformData( + _query: FedFredSearchQueryParams, + data: Record[], + ) { + return data.map(d => FredSearchDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/models/fred-series.ts b/packages/opentypebb/src/providers/federal_reserve/models/fred-series.ts new file mode 100644 index 00000000..a41d5975 --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/fred-series.ts @@ -0,0 +1,47 @@ +/** + * Federal Reserve FRED Series Fetcher. + * Fetches observations for one or more FRED series. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { FredSeriesQueryParamsSchema, FredSeriesDataSchema } from '../../../standard-models/fred-series.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchFredMultiSeries, multiSeriesToRecords, getFredApiKey } from '../utils/fred-helpers.js' + +export const FedFredSeriesQueryParamsSchema = FredSeriesQueryParamsSchema +export type FedFredSeriesQueryParams = z.infer + +export class FedFredSeriesFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedFredSeriesQueryParams { + return FedFredSeriesQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedFredSeriesQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = getFredApiKey(credentials) + const seriesIds = query.symbol.split(',').map(s => s.trim()).filter(Boolean) + if (seriesIds.length === 0) throw new EmptyDataError('No series IDs provided.') + + const dataMap = await fetchFredMultiSeries(seriesIds, apiKey, { + startDate: query.start_date, + endDate: query.end_date, + limit: query.limit ?? undefined, + }) + + const records = multiSeriesToRecords(dataMap) + if (records.length === 0) throw new EmptyDataError('No FRED series data found.') + return records + } + + static override transformData( + _query: FedFredSeriesQueryParams, + data: Record[], + ) { + return data.map(d => FredSeriesDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/models/inflation-expectations.ts b/packages/opentypebb/src/providers/federal_reserve/models/inflation-expectations.ts new file mode 100644 index 00000000..5c5f70ea --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/inflation-expectations.ts @@ -0,0 +1,51 @@ +/** + * Federal Reserve Inflation Expectations Fetcher. + * Uses FRED series: MICH (Michigan 1y), MICH5Y (Michigan 5y), + * T5YIE (5y Breakeven), T10YIE (10y Breakeven). + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { InflationExpectationsQueryParamsSchema, InflationExpectationsDataSchema } from '../../../standard-models/inflation-expectations.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchFredMultiSeries, multiSeriesToRecords, getFredApiKey } from '../utils/fred-helpers.js' + +export const FedInflationExpectationsQueryParamsSchema = InflationExpectationsQueryParamsSchema +export type FedInflationExpectationsQueryParams = z.infer + +const SERIES = ['MICH', 'T5YIE', 'T10YIE'] +const FIELD_MAP: Record = { + MICH: 'michigan_1y', + T5YIE: 'breakeven_5y', + T10YIE: 'breakeven_10y', +} + +export class FedInflationExpectationsFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedInflationExpectationsQueryParams { + return FedInflationExpectationsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedInflationExpectationsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = getFredApiKey(credentials) + const dataMap = await fetchFredMultiSeries(SERIES, apiKey, { + startDate: query.start_date, + endDate: query.end_date, + }) + + const records = multiSeriesToRecords(dataMap, FIELD_MAP) + if (records.length === 0) throw new EmptyDataError('No inflation expectations data found.') + return records + } + + static override transformData( + _query: FedInflationExpectationsQueryParams, + data: Record[], + ) { + return data.map(d => InflationExpectationsDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/models/manufacturing-outlook-ny.ts b/packages/opentypebb/src/providers/federal_reserve/models/manufacturing-outlook-ny.ts new file mode 100644 index 00000000..90682be9 --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/manufacturing-outlook-ny.ts @@ -0,0 +1,44 @@ +/** + * Federal Reserve NY Manufacturing Outlook (Empire State) Fetcher. + * Uses FRED series: GACDISA066MSFRBNY (General Business Conditions). + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { ManufacturingOutlookNYQueryParamsSchema, ManufacturingOutlookNYDataSchema } from '../../../standard-models/manufacturing-outlook-ny.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchFredSeries, getFredApiKey } from '../utils/fred-helpers.js' + +export const FedManufacturingOutlookNYQueryParamsSchema = ManufacturingOutlookNYQueryParamsSchema +export type FedManufacturingOutlookNYQueryParams = z.infer + +export class FedManufacturingOutlookNYFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedManufacturingOutlookNYQueryParams { + return FedManufacturingOutlookNYQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedManufacturingOutlookNYQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = getFredApiKey(credentials) + const observations = await fetchFredSeries('GACDISA066MSFRBNY', apiKey, { + startDate: query.start_date, + endDate: query.end_date, + }) + if (observations.length === 0) throw new EmptyDataError('No Empire State Manufacturing data found.') + return observations.map(o => ({ + date: o.date, + general_business_conditions: parseFloat(o.value), + })) + } + + static override transformData( + _query: FedManufacturingOutlookNYQueryParams, + data: Record[], + ) { + return data.map(d => ManufacturingOutlookNYDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/models/manufacturing-outlook-texas.ts b/packages/opentypebb/src/providers/federal_reserve/models/manufacturing-outlook-texas.ts new file mode 100644 index 00000000..0f359d76 --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/manufacturing-outlook-texas.ts @@ -0,0 +1,46 @@ +/** + * Federal Reserve Dallas Fed Manufacturing Outlook Fetcher. + * Uses FRED series: DALLASMANOUTGEN (General Activity). + * Note: actual FRED ID may vary; falls back to BCTDAL for Dallas Fed data. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { ManufacturingOutlookTexasQueryParamsSchema, ManufacturingOutlookTexasDataSchema } from '../../../standard-models/manufacturing-outlook-texas.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchFredSeries, getFredApiKey } from '../utils/fred-helpers.js' + +export const FedManufacturingOutlookTexasQueryParamsSchema = ManufacturingOutlookTexasQueryParamsSchema +export type FedManufacturingOutlookTexasQueryParams = z.infer + +export class FedManufacturingOutlookTexasFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedManufacturingOutlookTexasQueryParams { + return FedManufacturingOutlookTexasQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedManufacturingOutlookTexasQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = getFredApiKey(credentials) + // Dallas Fed Texas Manufacturing Outlook Survey — General Business Activity + const observations = await fetchFredSeries('BCTDAL', apiKey, { + startDate: query.start_date, + endDate: query.end_date, + }) + if (observations.length === 0) throw new EmptyDataError('No Dallas Fed Manufacturing data found.') + return observations.map(o => ({ + date: o.date, + general_activity: parseFloat(o.value), + })) + } + + static override transformData( + _query: FedManufacturingOutlookTexasQueryParams, + data: Record[], + ) { + return data.map(d => ManufacturingOutlookTexasDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/models/money-measures.ts b/packages/opentypebb/src/providers/federal_reserve/models/money-measures.ts new file mode 100644 index 00000000..08659af5 --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/money-measures.ts @@ -0,0 +1,52 @@ +/** + * Federal Reserve Money Measures Fetcher. + * Uses FRED series: M1SL (M1), M2SL (M2) — seasonally adjusted. + * Or: M1NS, M2NS — not adjusted. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { MoneyMeasuresQueryParamsSchema, MoneyMeasuresDataSchema } from '../../../standard-models/money-measures.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchFredMultiSeries, multiSeriesToRecords, getFredApiKey } from '../utils/fred-helpers.js' + +export const FedMoneyMeasuresQueryParamsSchema = MoneyMeasuresQueryParamsSchema +export type FedMoneyMeasuresQueryParams = z.infer + +export class FedMoneyMeasuresFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedMoneyMeasuresQueryParams { + return FedMoneyMeasuresQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedMoneyMeasuresQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = getFredApiKey(credentials) + const adjusted = query.adjusted !== false + const m1Series = adjusted ? 'M1SL' : 'M1NS' + const m2Series = adjusted ? 'M2SL' : 'M2NS' + + const dataMap = await fetchFredMultiSeries([m1Series, m2Series], apiKey, { + startDate: query.start_date, + endDate: query.end_date, + }) + + const fieldMap: Record = { + [m1Series]: 'm1', + [m2Series]: 'm2', + } + const records = multiSeriesToRecords(dataMap, fieldMap) + if (records.length === 0) throw new EmptyDataError('No money measures data found.') + return records + } + + static override transformData( + _query: FedMoneyMeasuresQueryParams, + data: Record[], + ) { + return data.map(d => MoneyMeasuresDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/models/nonfarm-payrolls.ts b/packages/opentypebb/src/providers/federal_reserve/models/nonfarm-payrolls.ts new file mode 100644 index 00000000..9968d08b --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/nonfarm-payrolls.ts @@ -0,0 +1,50 @@ +/** + * Federal Reserve Nonfarm Payrolls Fetcher. + * Uses FRED series: PAYEMS (Total Nonfarm), USPRIV (Private), USGOVT (Government). + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { NonfarmPayrollsQueryParamsSchema, NonfarmPayrollsDataSchema } from '../../../standard-models/nonfarm-payrolls.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchFredMultiSeries, multiSeriesToRecords, getFredApiKey } from '../utils/fred-helpers.js' + +export const FedNonfarmPayrollsQueryParamsSchema = NonfarmPayrollsQueryParamsSchema +export type FedNonfarmPayrollsQueryParams = z.infer + +const SERIES = ['PAYEMS', 'USPRIV', 'USGOVT'] +const FIELD_MAP: Record = { + PAYEMS: 'total_nonfarm', + USPRIV: 'private_sector', + USGOVT: 'government', +} + +export class FedNonfarmPayrollsFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedNonfarmPayrollsQueryParams { + return FedNonfarmPayrollsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedNonfarmPayrollsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = getFredApiKey(credentials) + const dataMap = await fetchFredMultiSeries(SERIES, apiKey, { + startDate: query.start_date, + endDate: query.end_date, + }) + + const records = multiSeriesToRecords(dataMap, FIELD_MAP) + if (records.length === 0) throw new EmptyDataError('No nonfarm payrolls data found.') + return records + } + + static override transformData( + _query: FedNonfarmPayrollsQueryParams, + data: Record[], + ) { + return data.map(d => NonfarmPayrollsDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/models/pce.ts b/packages/opentypebb/src/providers/federal_reserve/models/pce.ts new file mode 100644 index 00000000..24948974 --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/pce.ts @@ -0,0 +1,46 @@ +/** + * Federal Reserve PCE Fetcher. + * Uses FRED series: PCEPI (PCE Price Index), PCEPILFE (Core PCE). + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { PersonalConsumptionExpendituresQueryParamsSchema, PersonalConsumptionExpendituresDataSchema } from '../../../standard-models/pce.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchFredMultiSeries, multiSeriesToRecords, getFredApiKey } from '../utils/fred-helpers.js' + +export const FedPCEQueryParamsSchema = PersonalConsumptionExpendituresQueryParamsSchema +export type FedPCEQueryParams = z.infer + +export class FedPCEFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedPCEQueryParams { + return FedPCEQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedPCEQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = getFredApiKey(credentials) + const dataMap = await fetchFredMultiSeries(['PCEPI', 'PCEPILFE'], apiKey, { + startDate: query.start_date, + endDate: query.end_date, + }) + + const records = multiSeriesToRecords(dataMap, { + PCEPI: 'pce', + PCEPILFE: 'core_pce', + }) + if (records.length === 0) throw new EmptyDataError('No PCE data found.') + return records + } + + static override transformData( + _query: FedPCEQueryParams, + data: Record[], + ) { + return data.map(d => PersonalConsumptionExpendituresDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/models/primary-dealer-fails.ts b/packages/opentypebb/src/providers/federal_reserve/models/primary-dealer-fails.ts new file mode 100644 index 00000000..b3e2cbc0 --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/primary-dealer-fails.ts @@ -0,0 +1,50 @@ +/** + * Federal Reserve Primary Dealer Fails Fetcher. + * Uses FRED series for delivery failures data. + * Series: DTBSPCKF (Fails to Deliver), DTBSPCKR (Fails to Receive). + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { PrimaryDealerFailsQueryParamsSchema, PrimaryDealerFailsDataSchema } from '../../../standard-models/primary-dealer-fails.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchFredMultiSeries, multiSeriesToRecords, getFredApiKey } from '../utils/fred-helpers.js' + +export const FedPrimaryDealerFailsQueryParamsSchema = PrimaryDealerFailsQueryParamsSchema +export type FedPrimaryDealerFailsQueryParams = z.infer + +const SERIES = ['DTBSPCKF', 'DTBSPCKR'] +const FIELD_MAP: Record = { + DTBSPCKF: 'fails_to_deliver', + DTBSPCKR: 'fails_to_receive', +} + +export class FedPrimaryDealerFailsFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedPrimaryDealerFailsQueryParams { + return FedPrimaryDealerFailsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedPrimaryDealerFailsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = getFredApiKey(credentials) + const dataMap = await fetchFredMultiSeries(SERIES, apiKey, { + startDate: query.start_date, + endDate: query.end_date, + }) + + const records = multiSeriesToRecords(dataMap, FIELD_MAP) + if (records.length === 0) throw new EmptyDataError('No primary dealer fails data found.') + return records + } + + static override transformData( + _query: FedPrimaryDealerFailsQueryParams, + data: Record[], + ) { + return data.map(d => PrimaryDealerFailsDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/models/primary-dealer-positioning.ts b/packages/opentypebb/src/providers/federal_reserve/models/primary-dealer-positioning.ts new file mode 100644 index 00000000..72b0c9d9 --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/primary-dealer-positioning.ts @@ -0,0 +1,52 @@ +/** + * Federal Reserve Primary Dealer Positioning Fetcher. + * Uses NY Fed Primary Dealer Statistics via FRED. + * Series: PDTNCNET (Total Net Positions), etc. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { PrimaryDealerPositioningQueryParamsSchema, PrimaryDealerPositioningDataSchema } from '../../../standard-models/primary-dealer-positioning.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchFredMultiSeries, multiSeriesToRecords, getFredApiKey } from '../utils/fred-helpers.js' + +export const FedPrimaryDealerPositioningQueryParamsSchema = PrimaryDealerPositioningQueryParamsSchema +export type FedPrimaryDealerPositioningQueryParams = z.infer + +// Primary Dealer FRED series +const SERIES = ['PDTNCNET', 'PDUSTTOT', 'PDMBSTOT'] +const FIELD_MAP: Record = { + PDTNCNET: 'total_net_position', + PDUSTTOT: 'treasury_total', + PDMBSTOT: 'mbs_total', +} + +export class FedPrimaryDealerPositioningFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedPrimaryDealerPositioningQueryParams { + return FedPrimaryDealerPositioningQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedPrimaryDealerPositioningQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = getFredApiKey(credentials) + const dataMap = await fetchFredMultiSeries(SERIES, apiKey, { + startDate: query.start_date, + endDate: query.end_date, + }) + + const records = multiSeriesToRecords(dataMap, FIELD_MAP) + if (records.length === 0) throw new EmptyDataError('No primary dealer positioning data found.') + return records + } + + static override transformData( + _query: FedPrimaryDealerPositioningQueryParams, + data: Record[], + ) { + return data.map(d => PrimaryDealerPositioningDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/models/sloos.ts b/packages/opentypebb/src/providers/federal_reserve/models/sloos.ts new file mode 100644 index 00000000..5abb3e78 --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/sloos.ts @@ -0,0 +1,49 @@ +/** + * Federal Reserve SLOOS (Senior Loan Officer Opinion Survey) Fetcher. + * Uses FRED series: DRTSCILM (C&I Loan Tightening), DRTSCLCC (Consumer Loan Tightening). + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { SloosQueryParamsSchema, SloosDataSchema } from '../../../standard-models/sloos.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchFredMultiSeries, multiSeriesToRecords, getFredApiKey } from '../utils/fred-helpers.js' + +export const FedSloosQueryParamsSchema = SloosQueryParamsSchema +export type FedSloosQueryParams = z.infer + +const SERIES = ['DRTSCILM', 'DRTSCLCC'] +const FIELD_MAP: Record = { + DRTSCILM: 'ci_loan_tightening', + DRTSCLCC: 'consumer_loan_tightening', +} + +export class FedSloosFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedSloosQueryParams { + return FedSloosQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedSloosQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = getFredApiKey(credentials) + const dataMap = await fetchFredMultiSeries(SERIES, apiKey, { + startDate: query.start_date, + endDate: query.end_date, + }) + + const records = multiSeriesToRecords(dataMap, FIELD_MAP) + if (records.length === 0) throw new EmptyDataError('No SLOOS data found.') + return records + } + + static override transformData( + _query: FedSloosQueryParams, + data: Record[], + ) { + return data.map(d => SloosDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/models/total-factor-productivity.ts b/packages/opentypebb/src/providers/federal_reserve/models/total-factor-productivity.ts new file mode 100644 index 00000000..30659236 --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/total-factor-productivity.ts @@ -0,0 +1,44 @@ +/** + * Federal Reserve Total Factor Productivity Fetcher. + * Uses FRED series: RTFPNAUSA632NRUG (Annual TFP at constant national prices for US). + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { TotalFactorProductivityQueryParamsSchema, TotalFactorProductivityDataSchema } from '../../../standard-models/total-factor-productivity.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchFredSeries, getFredApiKey } from '../utils/fred-helpers.js' + +export const FedTFPQueryParamsSchema = TotalFactorProductivityQueryParamsSchema +export type FedTFPQueryParams = z.infer + +export class FedTotalFactorProductivityFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedTFPQueryParams { + return FedTFPQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedTFPQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = getFredApiKey(credentials) + const observations = await fetchFredSeries('RTFPNAUSA632NRUG', apiKey, { + startDate: query.start_date, + endDate: query.end_date, + }) + if (observations.length === 0) throw new EmptyDataError('No TFP data found.') + return observations.map(o => ({ + date: o.date, + value: parseFloat(o.value), + })) + } + + static override transformData( + _query: FedTFPQueryParams, + data: Record[], + ) { + return data.map(d => TotalFactorProductivityDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/models/unemployment.ts b/packages/opentypebb/src/providers/federal_reserve/models/unemployment.ts new file mode 100644 index 00000000..f0a7a3a7 --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/unemployment.ts @@ -0,0 +1,52 @@ +/** + * Federal Reserve Unemployment Fetcher. + * Uses FRED series: UNRATE (U-3), U6RATE (U-6). + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { UnemploymentQueryParamsSchema, UnemploymentDataSchema } from '../../../standard-models/unemployment.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchFredSeries, getFredApiKey } from '../utils/fred-helpers.js' + +export const FedUnemploymentQueryParamsSchema = UnemploymentQueryParamsSchema +export type FedUnemploymentQueryParams = z.infer + +export class FedUnemploymentFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedUnemploymentQueryParams { + return FedUnemploymentQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedUnemploymentQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = getFredApiKey(credentials) + const observations = await fetchFredSeries('UNRATE', apiKey, { + startDate: query.start_date, + endDate: query.end_date, + }) + + if (observations.length === 0) throw new EmptyDataError('No unemployment data found.') + return observations.map(o => ({ + date: o.date, + country: 'United States', + value: parseFloat(o.value), + })) + } + + static override transformData( + query: FedUnemploymentQueryParams, + data: Record[], + ) { + if (data.length === 0) throw new EmptyDataError() + let filtered = data + if (query.start_date) filtered = filtered.filter(d => String(d.date) >= query.start_date!) + if (query.end_date) filtered = filtered.filter(d => String(d.date) <= query.end_date!) + return filtered + .sort((a, b) => String(a.date).localeCompare(String(b.date))) + .map(d => UnemploymentDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/models/university-of-michigan.ts b/packages/opentypebb/src/providers/federal_reserve/models/university-of-michigan.ts new file mode 100644 index 00000000..2dc7ed8c --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/models/university-of-michigan.ts @@ -0,0 +1,52 @@ +/** + * Federal Reserve University of Michigan Consumer Sentiment Fetcher. + * Uses FRED series: UMCSENT (Sentiment), UMCSENT1 is not available, so we use + * UMCSENT (Consumer Sentiment), CURRCOND (Current Conditions), EXPINF1YR + EXPINF5YR. + * Actual FRED IDs: UMCSENT, UMCSENT (we approximate with available data). + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { UniversityOfMichiganQueryParamsSchema, UniversityOfMichiganDataSchema } from '../../../standard-models/university-of-michigan.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchFredMultiSeries, multiSeriesToRecords, getFredApiKey } from '../utils/fred-helpers.js' + +export const FedUMichQueryParamsSchema = UniversityOfMichiganQueryParamsSchema +export type FedUMichQueryParams = z.infer + +// FRED series for Michigan survey data +const SERIES = ['UMCSENT', 'MICH'] +const FIELD_MAP: Record = { + UMCSENT: 'consumer_sentiment', + MICH: 'inflation_expectation_1y', +} + +export class FedUniversityOfMichiganFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): FedUMichQueryParams { + return FedUMichQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FedUMichQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = getFredApiKey(credentials) + const dataMap = await fetchFredMultiSeries(SERIES, apiKey, { + startDate: query.start_date, + endDate: query.end_date, + }) + + const records = multiSeriesToRecords(dataMap, FIELD_MAP) + if (records.length === 0) throw new EmptyDataError('No University of Michigan data found.') + return records + } + + static override transformData( + _query: FedUMichQueryParams, + data: Record[], + ) { + return data.map(d => UniversityOfMichiganDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/federal_reserve/utils/fred-helpers.ts b/packages/opentypebb/src/providers/federal_reserve/utils/fred-helpers.ts new file mode 100644 index 00000000..6f7659ae --- /dev/null +++ b/packages/opentypebb/src/providers/federal_reserve/utils/fred-helpers.ts @@ -0,0 +1,221 @@ +/** + * FRED API shared helpers. + * + * Provides reusable functions for fetching data from the + * Federal Reserve Economic Data (FRED) API. + */ + +import { amakeRequest } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' + +const FRED_BASE = 'https://api.stlouisfed.org/fred' + +export interface FredObservation { + date: string + value: string +} + +export interface FredSeriesInfo { + id: string + title: string + frequency_short: string + units_short: string + seasonal_adjustment_short: string + last_updated: string + notes: string +} + +/** + * Build a FRED API URL with common parameters. + */ +function buildFredUrl( + endpoint: string, + params: Record, + apiKey: string, +): string { + const url = new URL(`${FRED_BASE}/${endpoint}`) + url.searchParams.set('file_type', 'json') + if (apiKey) url.searchParams.set('api_key', apiKey) + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null && v !== '') { + url.searchParams.set(k, String(v)) + } + } + return url.toString() +} + +/** + * Fetch observations for a single FRED series. + */ +export async function fetchFredSeries( + seriesId: string, + apiKey: string, + opts: { + startDate?: string | null + endDate?: string | null + limit?: number + sortOrder?: 'asc' | 'desc' + frequency?: string + units?: string + } = {}, +): Promise { + const url = buildFredUrl('series/observations', { + series_id: seriesId, + observation_start: opts.startDate ?? undefined, + observation_end: opts.endDate ?? undefined, + limit: opts.limit, + sort_order: opts.sortOrder ?? 'asc', + frequency: opts.frequency, + units: opts.units, + }, apiKey) + + const data = await amakeRequest<{ observations?: FredObservation[] }>(url) + return (data.observations ?? []).filter(o => o.value !== '.') +} + +/** + * Fetch multiple FRED series and merge by date. + * Returns records keyed by date, with each series as a field. + */ +export async function fetchFredMultiSeries( + seriesIds: string[], + apiKey: string, + opts: { + startDate?: string | null + endDate?: string | null + limit?: number + frequency?: string + } = {}, +): Promise>> { + const dataMap: Record> = {} + + for (const seriesId of seriesIds) { + try { + const observations = await fetchFredSeries(seriesId, apiKey, { + startDate: opts.startDate, + endDate: opts.endDate, + limit: opts.limit, + frequency: opts.frequency, + }) + for (const obs of observations) { + const val = parseFloat(obs.value) + if (!dataMap[obs.date]) dataMap[obs.date] = {} + dataMap[obs.date][seriesId] = isNaN(val) ? null : val + } + } catch { + // Skip series that fail + } + } + + return dataMap +} + +/** + * Search FRED series by keyword. + */ +export async function fredSearchApi( + query: string, + apiKey: string, + opts: { limit?: number; offset?: number } = {}, +): Promise { + const url = buildFredUrl('series/search', { + search_text: query, + limit: opts.limit ?? 100, + offset: opts.offset ?? 0, + }, apiKey) + + const data = await amakeRequest<{ seriess?: FredSeriesInfo[] }>(url) + return data.seriess ?? [] +} + +/** + * Fetch a FRED release table. + */ +export async function fredReleaseTableApi( + releaseId: string, + apiKey: string, + opts: { elementId?: number; date?: string } = {}, +): Promise[]> { + const url = buildFredUrl('release/tables', { + release_id: releaseId, + element_id: opts.elementId, + include_observation_values: 'true', + observation_date: opts.date, + }, apiKey) + + const data = await amakeRequest<{ elements?: Record }>(url) + if (!data.elements) return [] + + return Object.values(data.elements).map(el => el as Record) +} + +/** + * Fetch FRED regional/GeoFRED data. + */ +export async function fredRegionalApi( + seriesGroup: string, + apiKey: string, + opts: { + regionType?: string + date?: string + startDate?: string + seasonalAdjustment?: string + units?: string + frequency?: string + transformationCode?: string + } = {}, +): Promise[]> { + const url = buildFredUrl('geofred/series/data', { + series_group: seriesGroup, + region_type: opts.regionType ?? 'state', + date: opts.date, + start_date: opts.startDate, + season: opts.seasonalAdjustment ?? 'SA', + units: opts.units, + frequency: opts.frequency, + transformation: opts.transformationCode, + }, apiKey) + + const data = await amakeRequest<{ meta?: Record; data?: Record }>(url) + if (!data.data) return [] + + // GeoFRED returns { data: { "2024-01-01": [{ region: ..., value: ... }, ...] } } + const results: Record[] = [] + for (const [date, regions] of Object.entries(data.data)) { + if (Array.isArray(regions)) { + for (const region of regions) { + results.push({ date, ...(region as Record) }) + } + } + } + return results +} + +/** + * Convert a FRED multi-series result to an array of flat records. + */ +export function multiSeriesToRecords( + dataMap: Record>, + fieldMap?: Record, +): Record[] { + return Object.entries(dataMap) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, values]) => { + const record: Record = { date } + if (fieldMap) { + for (const [seriesId, fieldName] of Object.entries(fieldMap)) { + record[fieldName] = values[seriesId] ?? null + } + } else { + Object.assign(record, values) + } + return record + }) +} + +/** + * Get credentials helper — extracts FRED API key. + */ +export function getFredApiKey(credentials: Record | null): string { + return credentials?.fred_api_key ?? credentials?.api_key ?? '' +} diff --git a/packages/opentypebb/src/providers/fmp/index.ts b/packages/opentypebb/src/providers/fmp/index.ts new file mode 100644 index 00000000..fd6cb0d8 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/index.ts @@ -0,0 +1,150 @@ +/** + * FMP Provider Module. + * Maps to: openbb_platform/providers/fmp/openbb_fmp/__init__.py + * + * Only includes fetchers that have been ported to TypeScript. + * The Python version has ~70 fetchers; we port only what open-alice uses. + */ + +import { Provider } from '../../core/provider/abstract/provider.js' + +import { FMPEquityProfileFetcher } from './models/equity-profile.js' +import { FMPEquityQuoteFetcher } from './models/equity-quote.js' +import { FMPEquityHistoricalFetcher } from './models/equity-historical.js' +import { FMPBalanceSheetFetcher } from './models/balance-sheet.js' +import { FMPIncomeStatementFetcher } from './models/income-statement.js' +import { FMPCashFlowStatementFetcher } from './models/cash-flow.js' +import { FMPFinancialRatiosFetcher } from './models/financial-ratios.js' +import { FMPKeyMetricsFetcher } from './models/key-metrics.js' +import { FMPInsiderTradingFetcher } from './models/insider-trading.js' +import { FMPCalendarEarningsFetcher } from './models/calendar-earnings.js' +import { FMPCompanyNewsFetcher } from './models/company-news.js' +import { FMPWorldNewsFetcher } from './models/world-news.js' +import { FMPPriceTargetConsensusFetcher } from './models/price-target-consensus.js' +import { FMPGainersFetcher } from './models/gainers.js' +import { FMPLosersFetcher } from './models/losers.js' +import { FMPEquityActiveFetcher } from './models/active.js' +import { FMPCryptoHistoricalFetcher } from './models/crypto-historical.js' +import { FMPCryptoSearchFetcher } from './models/crypto-search.js' +import { FMPCurrencyHistoricalFetcher } from './models/currency-historical.js' +import { FMPCurrencyPairsFetcher } from './models/currency-pairs.js' +import { FMPBalanceSheetGrowthFetcher } from './models/balance-sheet-growth.js' +import { FMPIncomeStatementGrowthFetcher } from './models/income-statement-growth.js' +import { FMPCashFlowStatementGrowthFetcher } from './models/cash-flow-growth.js' +import { FMPCalendarDividendFetcher } from './models/calendar-dividend.js' +import { FMPCalendarSplitsFetcher } from './models/calendar-splits.js' +import { FMPCalendarIpoFetcher } from './models/calendar-ipo.js' +import { FMPEconomicCalendarFetcher } from './models/economic-calendar.js' +import { FMPAnalystEstimatesFetcher } from './models/analyst-estimates.js' +import { FMPForwardEpsEstimatesFetcher } from './models/forward-eps-estimates.js' +import { FMPForwardEbitdaEstimatesFetcher } from './models/forward-ebitda-estimates.js' +import { FMPPriceTargetFetcher } from './models/price-target.js' +import { FMPEtfInfoFetcher } from './models/etf-info.js' +import { FMPEtfHoldingsFetcher } from './models/etf-holdings.js' +import { FMPEtfSectorsFetcher } from './models/etf-sectors.js' +import { FMPEtfCountriesFetcher } from './models/etf-countries.js' +import { FMPEtfEquityExposureFetcher } from './models/etf-equity-exposure.js' +import { FMPEtfSearchFetcher } from './models/etf-search.js' +import { FMPKeyExecutivesFetcher } from './models/key-executives.js' +import { FMPExecutiveCompensationFetcher } from './models/executive-compensation.js' +import { FMPGovernmentTradesFetcher } from './models/government-trades.js' +import { FMPInstitutionalOwnershipFetcher } from './models/institutional-ownership.js' +import { FMPHistoricalDividendsFetcher } from './models/historical-dividends.js' +import { FMPHistoricalSplitsFetcher } from './models/historical-splits.js' +import { FMPHistoricalEpsFetcher } from './models/historical-eps.js' +import { FMPHistoricalEmployeesFetcher } from './models/historical-employees.js' +import { FMPShareStatisticsFetcher } from './models/share-statistics.js' +import { FMPEquityPeersFetcher } from './models/equity-peers.js' +import { FMPEquityScreenerFetcher } from './models/equity-screener.js' +import { FMPCompanyFilingsFetcher } from './models/company-filings.js' +import { FMPPricePerformanceFetcher } from './models/price-performance.js' +import { FMPMarketSnapshotsFetcher } from './models/market-snapshots.js' +import { FMPCurrencySnapshotsFetcher } from './models/currency-snapshots.js' +import { FMPAvailableIndicesFetcher } from './models/available-indices.js' +import { FMPIndexConstituentsFetcher } from './models/index-constituents.js' +import { FMPIndexHistoricalFetcher } from './models/index-historical.js' +import { FMPRiskPremiumFetcher } from './models/risk-premium.js' +import { FMPTreasuryRatesFetcher } from './models/treasury-rates.js' +import { FMPRevenueBusinessLineFetcher } from './models/revenue-business-line.js' +import { FMPRevenueGeographicFetcher } from './models/revenue-geographic.js' +import { FMPEarningsCallTranscriptFetcher } from './models/earnings-call-transcript.js' +import { FMPDiscoveryFilingsFetcher } from './models/discovery-filings.js' +import { FMPEsgScoreFetcher } from './models/esg-score.js' +import { FMPHistoricalMarketCapFetcher } from './models/historical-market-cap.js' + +export const fmpProvider = new Provider({ + name: 'fmp', + website: 'https://financialmodelingprep.com', + description: + 'Financial Modeling Prep is a new concept that informs you about ' + + 'stock market information (news, currencies, and stock prices).', + credentials: ['api_key'], + reprName: 'Financial Modeling Prep (FMP)', + fetcherDict: { + EquityInfo: FMPEquityProfileFetcher, + EquityQuote: FMPEquityQuoteFetcher, + EquityHistorical: FMPEquityHistoricalFetcher, + BalanceSheet: FMPBalanceSheetFetcher, + IncomeStatement: FMPIncomeStatementFetcher, + CashFlowStatement: FMPCashFlowStatementFetcher, + FinancialRatios: FMPFinancialRatiosFetcher, + KeyMetrics: FMPKeyMetricsFetcher, + InsiderTrading: FMPInsiderTradingFetcher, + CalendarEarnings: FMPCalendarEarningsFetcher, + CompanyNews: FMPCompanyNewsFetcher, + WorldNews: FMPWorldNewsFetcher, + PriceTargetConsensus: FMPPriceTargetConsensusFetcher, + EquityGainers: FMPGainersFetcher, + EquityLosers: FMPLosersFetcher, + EquityActive: FMPEquityActiveFetcher, + CryptoHistorical: FMPCryptoHistoricalFetcher, + CryptoSearch: FMPCryptoSearchFetcher, + CurrencyHistorical: FMPCurrencyHistoricalFetcher, + CurrencyPairs: FMPCurrencyPairsFetcher, + BalanceSheetGrowth: FMPBalanceSheetGrowthFetcher, + IncomeStatementGrowth: FMPIncomeStatementGrowthFetcher, + CashFlowStatementGrowth: FMPCashFlowStatementGrowthFetcher, + CalendarDividend: FMPCalendarDividendFetcher, + CalendarSplits: FMPCalendarSplitsFetcher, + CalendarIpo: FMPCalendarIpoFetcher, + EconomicCalendar: FMPEconomicCalendarFetcher, + AnalystEstimates: FMPAnalystEstimatesFetcher, + ForwardEpsEstimates: FMPForwardEpsEstimatesFetcher, + ForwardEbitdaEstimates: FMPForwardEbitdaEstimatesFetcher, + PriceTarget: FMPPriceTargetFetcher, + EtfInfo: FMPEtfInfoFetcher, + EtfHoldings: FMPEtfHoldingsFetcher, + EtfSectors: FMPEtfSectorsFetcher, + EtfCountries: FMPEtfCountriesFetcher, + EtfEquityExposure: FMPEtfEquityExposureFetcher, + EtfSearch: FMPEtfSearchFetcher, + KeyExecutives: FMPKeyExecutivesFetcher, + ExecutiveCompensation: FMPExecutiveCompensationFetcher, + GovernmentTrades: FMPGovernmentTradesFetcher, + InstitutionalOwnership: FMPInstitutionalOwnershipFetcher, + // EtfHistorical reuses the same fetcher as EquityHistorical (same pattern as Python) + EtfHistorical: FMPEquityHistoricalFetcher, + HistoricalDividends: FMPHistoricalDividendsFetcher, + HistoricalSplits: FMPHistoricalSplitsFetcher, + HistoricalEps: FMPHistoricalEpsFetcher, + HistoricalEmployees: FMPHistoricalEmployeesFetcher, + ShareStatistics: FMPShareStatisticsFetcher, + EquityPeers: FMPEquityPeersFetcher, + EquityScreener: FMPEquityScreenerFetcher, + CompanyFilings: FMPCompanyFilingsFetcher, + PricePerformance: FMPPricePerformanceFetcher, + MarketSnapshots: FMPMarketSnapshotsFetcher, + CurrencySnapshots: FMPCurrencySnapshotsFetcher, + AvailableIndices: FMPAvailableIndicesFetcher, + IndexConstituents: FMPIndexConstituentsFetcher, + IndexHistorical: FMPIndexHistoricalFetcher, + RiskPremium: FMPRiskPremiumFetcher, + TreasuryRates: FMPTreasuryRatesFetcher, + RevenueBusinessLine: FMPRevenueBusinessLineFetcher, + RevenueGeographic: FMPRevenueGeographicFetcher, + EarningsCallTranscript: FMPEarningsCallTranscriptFetcher, + DiscoveryFilings: FMPDiscoveryFilingsFetcher, + EsgScore: FMPEsgScoreFetcher, + HistoricalMarketCap: FMPHistoricalMarketCapFetcher, + }, +}) diff --git a/packages/opentypebb/src/providers/fmp/models/active.ts b/packages/opentypebb/src/providers/fmp/models/active.ts new file mode 100644 index 00000000..b9bbfce3 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/active.ts @@ -0,0 +1,49 @@ +/** + * FMP Most Active Model. + * Maps to: openbb_fmp/models/equity_most_active.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityPerformanceQueryParamsSchema, EquityPerformanceDataSchema } from '../../../standard-models/equity-performance.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { percent_change: 'changesPercentage' } + +export const FMPEquityActiveQueryParamsSchema = EquityPerformanceQueryParamsSchema +export type FMPEquityActiveQueryParams = z.infer + +export const FMPEquityActiveDataSchema = EquityPerformanceDataSchema.extend({ + exchange: z.string().describe('Stock exchange where the security is listed.'), +}).passthrough() +export type FMPEquityActiveData = z.infer + +export class FMPEquityActiveFetcher extends Fetcher { + static override transformQuery(params: Record): FMPEquityActiveQueryParams { + return FMPEquityActiveQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPEquityActiveQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + return getDataMany(`https://financialmodelingprep.com/stable/most-actives?apikey=${apiKey}`) + } + + static override transformData( + query: FMPEquityActiveQueryParams, + data: Record[], + ): FMPEquityActiveData[] { + const sorted = [...data].sort((a, b) => { + const diff = Number(b.changesPercentage ?? 0) - Number(a.changesPercentage ?? 0) + return query.sort === 'desc' ? diff : -diff + }) + return sorted.map((d) => { + const aliased = applyAliases(d, ALIAS_DICT) + if (typeof aliased.percent_change === 'number') aliased.percent_change = aliased.percent_change / 100 + return FMPEquityActiveDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/analyst-estimates.ts b/packages/opentypebb/src/providers/fmp/models/analyst-estimates.ts new file mode 100644 index 00000000..a02cb231 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/analyst-estimates.ts @@ -0,0 +1,78 @@ +/** + * FMP Analyst Estimates Model. + * Maps to: openbb_fmp/models/analyst_estimates.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { AnalystEstimatesQueryParamsSchema, AnalystEstimatesDataSchema } from '../../../standard-models/analyst-estimates.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPAnalystEstimatesQueryParamsSchema = AnalystEstimatesQueryParamsSchema.extend({ + period: z.enum(['annual', 'quarter']).default('annual').describe('Time period of the data to return.'), + limit: z.coerce.number().int().nullable().default(null).describe('The number of data entries to return.'), +}) + +export type FMPAnalystEstimatesQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + estimated_revenue_low: 'revenueLow', + estimated_revenue_high: 'revenueHigh', + estimated_revenue_avg: 'revenueAvg', + estimated_sga_expense_low: 'sgaExpenseLow', + estimated_sga_expense_high: 'sgaExpenseHigh', + estimated_sga_expense_avg: 'sgaExpenseAvg', + estimated_ebitda_low: 'ebitdaLow', + estimated_ebitda_high: 'ebitdaHigh', + estimated_ebitda_avg: 'ebitdaAvg', + estimated_ebit_low: 'ebitLow', + estimated_ebit_high: 'ebitHigh', + estimated_ebit_avg: 'ebitAvg', + estimated_net_income_low: 'netIncomeLow', + estimated_net_income_high: 'netIncomeHigh', + estimated_net_income_avg: 'netIncomeAvg', + estimated_eps_low: 'epsLow', + estimated_eps_high: 'epsHigh', + estimated_eps_avg: 'epsAvg', + number_analyst_estimated_revenue: 'numAnalystsRevenue', + number_analysts_estimated_eps: 'numAnalystsEps', +} + +export const FMPAnalystEstimatesDataSchema = AnalystEstimatesDataSchema.extend({}).passthrough() +export type FMPAnalystEstimatesData = z.infer + +// --- Fetcher --- + +export class FMPAnalystEstimatesFetcher extends Fetcher { + static override transformQuery(params: Record): FMPAnalystEstimatesQueryParams { + return FMPAnalystEstimatesQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPAnalystEstimatesQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const url = 'https://financialmodelingprep.com/stable/analyst-estimates' + + `?symbol=${query.symbol}` + + `&period=${query.period}` + + (query.limit ? `&limit=${query.limit}` : '') + + `&apikey=${apiKey}` + return getDataMany(url) + } + + static override transformData( + query: FMPAnalystEstimatesQueryParams, + data: Record[], + ): FMPAnalystEstimatesData[] { + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPAnalystEstimatesDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/available-indices.ts b/packages/opentypebb/src/providers/fmp/models/available-indices.ts new file mode 100644 index 00000000..d2242666 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/available-indices.ts @@ -0,0 +1,38 @@ +/** + * FMP Available Indices Model. + * Maps to: openbb_fmp/models/available_indices.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { AvailableIndicesQueryParamsSchema, AvailableIndicesDataSchema } from '../../../standard-models/available-indices.js' +import { getDataMany } from '../utils/helpers.js' + +export const FMPAvailableIndicesQueryParamsSchema = AvailableIndicesQueryParamsSchema +export type FMPAvailableIndicesQueryParams = z.infer + +export const FMPAvailableIndicesDataSchema = AvailableIndicesDataSchema +export type FMPAvailableIndicesData = z.infer + +export class FMPAvailableIndicesFetcher extends Fetcher { + static override transformQuery(params: Record): FMPAvailableIndicesQueryParams { + return FMPAvailableIndicesQueryParamsSchema.parse(params) + } + + static override async extractData( + _query: FMPAvailableIndicesQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + return getDataMany( + `https://financialmodelingprep.com/stable/index-list?apikey=${apiKey}`, + ) + } + + static override transformData( + _query: FMPAvailableIndicesQueryParams, + data: Record[], + ): FMPAvailableIndicesData[] { + return data.map(d => FMPAvailableIndicesDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/balance-sheet-growth.ts b/packages/opentypebb/src/providers/fmp/models/balance-sheet-growth.ts new file mode 100644 index 00000000..5f4c1b88 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/balance-sheet-growth.ts @@ -0,0 +1,125 @@ +/** + * FMP Balance Sheet Growth Model. + * Maps to: openbb_fmp/models/balance_sheet_growth.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { BalanceSheetGrowthQueryParamsSchema, BalanceSheetGrowthDataSchema } from '../../../standard-models/balance-sheet-growth.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPBalanceSheetGrowthQueryParamsSchema = BalanceSheetGrowthQueryParamsSchema.extend({ + period: z.enum(['annual', 'quarter']).default('annual').describe('Time period of the data to return.'), + limit: z.coerce.number().int().nullable().default(null).describe('The number of data entries to return.'), +}) + +export type FMPBalanceSheetGrowthQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + period_ending: 'date', + fiscal_year: 'calendarYear', + fiscal_period: 'period', + reported_currency: 'reportedCurrency', + growth_other_total_shareholders_equity: 'growthOthertotalStockholdersEquity', + growth_total_shareholders_equity: 'growthTotalStockholdersEquity', + growth_total_liabilities_and_shareholders_equity: 'growthTotalLiabilitiesAndStockholdersEquity', + growth_accumulated_other_comprehensive_income: 'growthAccumulatedOtherComprehensiveIncomeLoss', + growth_prepaid_expenses: 'growthPrepaids', +} + +const pctOrNull = z.number().nullable().default(null) + +export const FMPBalanceSheetGrowthDataSchema = BalanceSheetGrowthDataSchema.extend({ + symbol: z.string().nullable().default(null).describe('The stock ticker symbol.'), + reported_currency: z.string().nullable().default(null).describe('The currency in which the financial data is reported.'), + growth_cash_and_cash_equivalents: pctOrNull.describe('Growth rate of cash and cash equivalents.'), + growth_short_term_investments: pctOrNull.describe('Growth rate of short-term investments.'), + growth_cash_and_short_term_investments: pctOrNull.describe('Growth rate of cash and short-term investments.'), + growth_accounts_receivables: pctOrNull.describe('Growth rate of accounts receivable.'), + growth_other_receivables: pctOrNull.describe('Growth rate of other receivables.'), + growth_net_receivables: pctOrNull.describe('Growth rate of net receivables.'), + growth_inventory: pctOrNull.describe('Growth rate of inventory.'), + growth_other_current_assets: pctOrNull.describe('Growth rate of other current assets.'), + growth_total_current_assets: pctOrNull.describe('Growth rate of total current assets.'), + growth_property_plant_equipment_net: pctOrNull.describe('Growth rate of net property, plant, and equipment.'), + growth_goodwill: pctOrNull.describe('Growth rate of goodwill.'), + growth_intangible_assets: pctOrNull.describe('Growth rate of intangible assets.'), + growth_goodwill_and_intangible_assets: pctOrNull.describe('Growth rate of goodwill and intangible assets.'), + growth_long_term_investments: pctOrNull.describe('Growth rate of long-term investments.'), + growth_tax_assets: pctOrNull.describe('Growth rate of tax assets.'), + growth_other_non_current_assets: pctOrNull.describe('Growth rate of other non-current assets.'), + growth_total_non_current_assets: pctOrNull.describe('Growth rate of total non-current assets.'), + growth_other_assets: pctOrNull.describe('Growth rate of other assets.'), + growth_total_assets: pctOrNull.describe('Growth rate of total assets.'), + growth_account_payables: pctOrNull.describe('Growth rate of accounts payable.'), + growth_other_payables: pctOrNull.describe('Growth rate of other payables.'), + growth_total_payables: pctOrNull.describe('Growth rate of total payables.'), + growth_accrued_expenses: pctOrNull.describe('Growth rate of accrued expenses.'), + growth_prepaid_expenses: pctOrNull.describe('Growth rate of prepaid expenses.'), + growth_capital_lease_obligations_current: pctOrNull.describe('Growth rate of current capital lease obligations.'), + growth_short_term_debt: pctOrNull.describe('Growth rate of short-term debt.'), + growth_tax_payables: pctOrNull.describe('Growth rate of tax payables.'), + growth_deferred_tax_liabilities_non_current: pctOrNull.describe('Growth rate of non-current deferred tax liabilities.'), + growth_deferred_revenue: pctOrNull.describe('Growth rate of deferred revenue.'), + growth_other_current_liabilities: pctOrNull.describe('Growth rate of other current liabilities.'), + growth_total_current_liabilities: pctOrNull.describe('Growth rate of total current liabilities.'), + growth_deferred_revenue_non_current: pctOrNull.describe('Growth rate of non-current deferred revenue.'), + growth_long_term_debt: pctOrNull.describe('Growth rate of long-term debt.'), + growth_deferrred_tax_liabilities_non_current: pctOrNull.describe('Growth rate of non-current deferred tax liabilities (alternate).'), + growth_other_non_current_liabilities: pctOrNull.describe('Growth rate of other non-current liabilities.'), + growth_total_non_current_liabilities: pctOrNull.describe('Growth rate of total non-current liabilities.'), + growth_other_liabilities: pctOrNull.describe('Growth rate of other liabilities.'), + growth_total_liabilities: pctOrNull.describe('Growth rate of total liabilities.'), + growth_retained_earnings: pctOrNull.describe('Growth rate of retained earnings.'), + growth_accumulated_other_comprehensive_income: pctOrNull.describe('Growth rate of accumulated other comprehensive income/loss.'), + growth_minority_interest: pctOrNull.describe('Growth rate of minority interest.'), + growth_additional_paid_in_capital: pctOrNull.describe('Growth rate of additional paid-in capital.'), + growth_other_total_shareholders_equity: pctOrNull.describe("Growth rate of other total stockholders' equity."), + growth_total_shareholders_equity: pctOrNull.describe("Growth rate of total stockholders' equity."), + growth_common_stock: pctOrNull.describe('Growth rate of common stock.'), + growth_preferred_stock: pctOrNull.describe('Growth rate of preferred stock.'), + growth_treasury_stock: pctOrNull.describe('Growth rate of treasury stock.'), + growth_total_equity: pctOrNull.describe('Growth rate of total equity.'), + growth_total_liabilities_and_shareholders_equity: pctOrNull.describe("Growth rate of total liabilities and stockholders' equity."), + growth_total_investments: pctOrNull.describe('Growth rate of total investments.'), + growth_total_debt: pctOrNull.describe('Growth rate of total debt.'), + growth_net_debt: pctOrNull.describe('Growth rate of net debt.'), +}).passthrough() + +export type FMPBalanceSheetGrowthData = z.infer + +// --- Fetcher --- + +export class FMPBalanceSheetGrowthFetcher extends Fetcher { + static override transformQuery(params: Record): FMPBalanceSheetGrowthQueryParams { + return FMPBalanceSheetGrowthQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPBalanceSheetGrowthQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const url = 'https://financialmodelingprep.com/stable/balance-sheet-statement-growth' + + `?symbol=${query.symbol}` + + `&period=${query.period}` + + `&limit=${query.limit ?? 5}` + + `&apikey=${apiKey}` + return getDataMany(url) + } + + static override transformData( + query: FMPBalanceSheetGrowthQueryParams, + data: Record[], + ): FMPBalanceSheetGrowthData[] { + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPBalanceSheetGrowthDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/balance-sheet.ts b/packages/opentypebb/src/providers/fmp/models/balance-sheet.ts new file mode 100644 index 00000000..4cee7d65 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/balance-sheet.ts @@ -0,0 +1,172 @@ +/** + * FMP Balance Sheet Model. + * Maps to: openbb_fmp/models/balance_sheet.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { BalanceSheetQueryParamsSchema, BalanceSheetDataSchema } from '../../../standard-models/balance-sheet.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' +import type { FinancialStatementPeriods } from '../utils/definitions.js' + +// --- Query Params --- + +export const FMPBalanceSheetQueryParamsSchema = BalanceSheetQueryParamsSchema.extend({ + period: z.enum(['q1', 'q2', 'q3', 'q4', 'fy', 'ttm', 'annual', 'quarter']).default('annual').describe('Time period of the data to return.'), +}) + +export type FMPBalanceSheetQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + period_ending: 'date', + fiscal_period: 'period', + fiscal_year: 'calendarYear', + filing_date: 'fillingDate', + accepted_date: 'acceptedDate', + reported_currency: 'reportedCurrency', + cash_and_cash_equivalents: 'cashAndCashEquivalents', + short_term_investments: 'shortTermInvestments', + cash_and_short_term_investments: 'cashAndShortTermInvestments', + net_receivables: 'netReceivables', + inventory: 'inventories', + other_current_assets: 'otherCurrentAssets', + total_current_assets: 'totalCurrentAssets', + plant_property_equipment_net: 'propertyPlantEquipmentNet', + goodwill: 'goodwill', + prepaid_expenses: 'prepaids', + intangible_assets: 'intangibleAssets', + goodwill_and_intangible_assets: 'goodwillAndIntangibleAssets', + long_term_investments: 'longTermInvestments', + tax_assets: 'taxAssets', + other_non_current_assets: 'otherNonCurrentAssets', + non_current_assets: 'totalNonCurrentAssets', + other_assets: 'otherAssets', + total_assets: 'totalAssets', + accounts_payable: 'accountPayables', + short_term_debt: 'shortTermDebt', + tax_payables: 'taxPayables', + current_deferred_revenue: 'deferredRevenue', + other_current_liabilities: 'otherCurrentLiabilities', + total_current_liabilities: 'totalCurrentLiabilities', + long_term_debt: 'longTermDebt', + deferred_revenue_non_current: 'deferredRevenueNonCurrent', + deferred_tax_liabilities_non_current: 'deferredTaxLiabilitiesNonCurrent', + other_non_current_liabilities: 'otherNonCurrentLiabilities', + total_non_current_liabilities: 'totalNonCurrentLiabilities', + other_liabilities: 'otherLiabilities', + capital_lease_obligations: 'capitalLeaseObligations', + total_liabilities: 'totalLiabilities', + preferred_stock: 'preferredStock', + common_stock: 'commonStock', + retained_earnings: 'retainedEarnings', + accumulated_other_comprehensive_income: 'accumulatedOtherComprehensiveIncomeLoss', + other_shareholders_equity: 'otherStockholdersEquity', + other_total_shareholders_equity: 'otherTotalStockholdersEquity', + total_common_equity: 'totalStockholdersEquity', + total_equity_non_controlling_interests: 'totalEquity', + total_liabilities_and_shareholders_equity: 'totalLiabilitiesAndStockholdersEquity', + minority_interest: 'minorityInterest', + total_liabilities_and_total_equity: 'totalLiabilitiesAndTotalEquity', + total_investments: 'totalInvestments', + total_debt: 'totalDebt', + net_debt: 'netDebt', +} + +const intOrNull = z.number().int().nullable().default(null) + +export const FMPBalanceSheetDataSchema = BalanceSheetDataSchema.extend({ + filing_date: z.string().nullable().default(null).describe('The date when the filing was made.'), + accepted_date: z.string().nullable().default(null).describe('The date and time when the filing was accepted.'), + cik: z.string().nullable().default(null).describe('The Central Index Key (CIK) assigned by the SEC.'), + symbol: z.string().nullable().default(null).describe('The stock ticker symbol.'), + reported_currency: z.string().nullable().default(null).describe('The currency in which the balance sheet was reported.'), + cash_and_cash_equivalents: intOrNull.describe('Cash and cash equivalents.'), + short_term_investments: intOrNull.describe('Short term investments.'), + cash_and_short_term_investments: intOrNull.describe('Cash and short term investments.'), + net_receivables: intOrNull.describe('Net receivables.'), + inventory: intOrNull.describe('Inventory.'), + other_current_assets: intOrNull.describe('Other current assets.'), + total_current_assets: intOrNull.describe('Total current assets.'), + plant_property_equipment_net: intOrNull.describe('Plant property equipment net.'), + goodwill: intOrNull.describe('Goodwill.'), + intangible_assets: intOrNull.describe('Intangible assets.'), + goodwill_and_intangible_assets: intOrNull.describe('Goodwill and intangible assets.'), + long_term_investments: intOrNull.describe('Long term investments.'), + tax_assets: intOrNull.describe('Tax assets.'), + other_non_current_assets: intOrNull.describe('Other non current assets.'), + non_current_assets: intOrNull.describe('Total non current assets.'), + other_assets: intOrNull.describe('Other assets.'), + total_assets: intOrNull.describe('Total assets.'), + accounts_payable: intOrNull.describe('Accounts payable.'), + prepaid_expenses: intOrNull.describe('Prepaid expenses.'), + short_term_debt: intOrNull.describe('Short term debt.'), + tax_payables: intOrNull.describe('Tax payables.'), + current_deferred_revenue: intOrNull.describe('Current deferred revenue.'), + other_current_liabilities: intOrNull.describe('Other current liabilities.'), + total_current_liabilities: intOrNull.describe('Total current liabilities.'), + long_term_debt: intOrNull.describe('Long term debt.'), + deferred_revenue_non_current: intOrNull.describe('Non current deferred revenue.'), + deferred_tax_liabilities_non_current: intOrNull.describe('Deferred tax liabilities non current.'), + other_non_current_liabilities: intOrNull.describe('Other non current liabilities.'), + total_non_current_liabilities: intOrNull.describe('Total non current liabilities.'), + capital_lease_obligations: intOrNull.describe('Capital lease obligations.'), + other_liabilities: intOrNull.describe('Other liabilities.'), + total_liabilities: intOrNull.describe('Total liabilities.'), + preferred_stock: intOrNull.describe('Preferred stock.'), + common_stock: intOrNull.describe('Common stock.'), + retained_earnings: intOrNull.describe('Retained earnings.'), + accumulated_other_comprehensive_income: intOrNull.describe('Accumulated other comprehensive income (loss).'), + other_shareholders_equity: intOrNull.describe('Other shareholders equity.'), + other_total_shareholders_equity: intOrNull.describe('Other total shareholders equity.'), + total_common_equity: intOrNull.describe('Total common equity.'), + total_equity_non_controlling_interests: intOrNull.describe('Total equity non controlling interests.'), + total_liabilities_and_shareholders_equity: intOrNull.describe('Total liabilities and shareholders equity.'), + minority_interest: intOrNull.describe('Minority interest.'), + total_liabilities_and_total_equity: intOrNull.describe('Total liabilities and total equity.'), + total_investments: intOrNull.describe('Total investments.'), + total_debt: intOrNull.describe('Total debt.'), + net_debt: intOrNull.describe('Net debt.'), +}).passthrough() + +export type FMPBalanceSheetData = z.infer + +// --- Fetcher --- + +export class FMPBalanceSheetFetcher extends Fetcher { + static override transformQuery(params: Record): FMPBalanceSheetQueryParams { + return FMPBalanceSheetQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPBalanceSheetQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + let baseUrl = 'https://financialmodelingprep.com/stable/balance-sheet-statement' + + if (query.period === 'ttm') { + baseUrl += '-ttm' + } + + const url = baseUrl + + `?symbol=${query.symbol}` + + (query.period !== 'ttm' ? `&period=${query.period}` : '') + + `&limit=${query.limit ?? 5}` + + `&apikey=${apiKey}` + + return getDataMany(url) + } + + static override transformData( + query: FMPBalanceSheetQueryParams, + data: Record[], + ): FMPBalanceSheetData[] { + return data.map((d) => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPBalanceSheetDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/calendar-dividend.ts b/packages/opentypebb/src/providers/fmp/models/calendar-dividend.ts new file mode 100644 index 00000000..1dc30db6 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/calendar-dividend.ts @@ -0,0 +1,74 @@ +/** + * FMP Dividend Calendar Model. + * Maps to: openbb_fmp/models/calendar_dividend.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CalendarDividendQueryParamsSchema, CalendarDividendDataSchema } from '../../../standard-models/calendar-dividend.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPCalendarDividendQueryParamsSchema = CalendarDividendQueryParamsSchema.extend({}) +export type FMPCalendarDividendQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + amount: 'dividend', + ex_dividend_date: 'date', + record_date: 'recordDate', + payment_date: 'paymentDate', + declaration_date: 'declarationDate', + adjusted_amount: 'adjDividend', + dividend_yield: 'yield', +} + +export const FMPCalendarDividendDataSchema = CalendarDividendDataSchema.extend({ + adjusted_amount: z.number().nullable().default(null).describe('The adjusted-dividend amount.'), + dividend_yield: z.number().nullable().default(null).describe('Annualized dividend yield.'), + frequency: z.string().nullable().default(null).describe('Frequency of the regular dividend payment.'), +}).passthrough() + +export type FMPCalendarDividendData = z.infer + +// --- Fetcher --- + +export class FMPCalendarDividendFetcher extends Fetcher { + static override transformQuery(params: Record): FMPCalendarDividendQueryParams { + return FMPCalendarDividendQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPCalendarDividendQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const now = new Date() + const startDate = query.start_date ?? now.toISOString().slice(0, 10) + const endDate = query.end_date ?? new Date(now.getTime() + 14 * 86400000).toISOString().slice(0, 10) + + const url = 'https://financialmodelingprep.com/stable/dividends-calendar' + + `?from=${startDate}&to=${endDate}&apikey=${apiKey}` + return getDataMany(url) + } + + static override transformData( + query: FMPCalendarDividendQueryParams, + data: Record[], + ): FMPCalendarDividendData[] { + const sorted = [...data].sort((a, b) => + String(a.date ?? '').localeCompare(String(b.date ?? '')), + ) + return sorted.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + // Normalize dividend_yield from percent to decimal + if (typeof aliased.dividend_yield === 'number') { + aliased.dividend_yield = aliased.dividend_yield / 100 + } + return FMPCalendarDividendDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/calendar-earnings.ts b/packages/opentypebb/src/providers/fmp/models/calendar-earnings.ts new file mode 100644 index 00000000..70d80b25 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/calendar-earnings.ts @@ -0,0 +1,106 @@ +/** + * FMP Earnings Calendar Model. + * Maps to: openbb_fmp/models/calendar_earnings.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CalendarEarningsQueryParamsSchema, CalendarEarningsDataSchema } from '../../../standard-models/calendar-earnings.js' +import { applyAliases, amakeRequest } from '../../../core/provider/utils/helpers.js' +import { responseCallback } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPCalendarEarningsQueryParamsSchema = CalendarEarningsQueryParamsSchema + +export type FMPCalendarEarningsQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + report_date: 'date', + eps_consensus: 'epsEstimated', + eps_actual: 'epsActual', + revenue_actual: 'revenueActual', + revenue_consensus: 'revenueEstimated', + last_updated: 'lastUpdated', +} + +export const FMPCalendarEarningsDataSchema = CalendarEarningsDataSchema.extend({ + eps_actual: z.number().nullable().default(null).describe('The actual earnings per share announced.'), + revenue_consensus: z.number().nullable().default(null).describe('The revenue forecast consensus.'), + revenue_actual: z.number().nullable().default(null).describe('The actual reported revenue.'), + last_updated: z.string().nullable().default(null).describe('The date the data was updated last.'), +}).passthrough() + +export type FMPCalendarEarningsData = z.infer + +// --- Fetcher --- + +export class FMPCalendarEarningsFetcher extends Fetcher { + static override transformQuery(params: Record): FMPCalendarEarningsQueryParams { + const now = new Date() + if (params.start_date == null) { + params.start_date = now.toISOString().split('T')[0] + } + if (params.end_date == null) { + const threeDaysLater = new Date(now) + threeDaysLater.setDate(threeDaysLater.getDate() + 3) + params.end_date = threeDaysLater.toISOString().split('T')[0] + } + return FMPCalendarEarningsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPCalendarEarningsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const baseUrl = 'https://financialmodelingprep.com/stable/earnings-calendar?' + const startDate = query.start_date ?? new Date().toISOString().split('T')[0] + const endDate = query.end_date ?? new Date().toISOString().split('T')[0] + + // Create 7-day chunks + const urls: string[] = [] + let currentStart = new Date(startDate) + const end = new Date(endDate) + + while (currentStart <= end) { + const chunkEnd = new Date(currentStart) + chunkEnd.setDate(chunkEnd.getDate() + 7) + const actualEnd = chunkEnd > end ? end : chunkEnd + + const from = currentStart.toISOString().split('T')[0] + const to = actualEnd.toISOString().split('T')[0] + urls.push(`${baseUrl}from=${from}&to=${to}&apikey=${apiKey}`) + + currentStart = new Date(actualEnd) + currentStart.setDate(currentStart.getDate() + 1) + } + + const allData: Record[] = [] + const results = await Promise.all( + urls.map((url) => amakeRequest[]>(url, { responseCallback }).catch(() => [])), + ) + + for (const batch of results) { + if (Array.isArray(batch)) allData.push(...batch) + } + + return allData + } + + static override transformData( + query: FMPCalendarEarningsQueryParams, + data: Record[], + ): FMPCalendarEarningsData[] { + const sorted = [...data].sort((a, b) => + String(b.date ?? '').localeCompare(String(a.date ?? '')), + ) + + return sorted.map((d) => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPCalendarEarningsDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/calendar-ipo.ts b/packages/opentypebb/src/providers/fmp/models/calendar-ipo.ts new file mode 100644 index 00000000..378584c7 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/calendar-ipo.ts @@ -0,0 +1,68 @@ +/** + * FMP IPO Calendar Model. + * Maps to: openbb_fmp/models/calendar_ipo.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CalendarIpoQueryParamsSchema, CalendarIpoDataSchema } from '../../../standard-models/calendar-ipo.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPCalendarIpoQueryParamsSchema = CalendarIpoQueryParamsSchema.extend({}) +export type FMPCalendarIpoQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + ipo_date: 'date', + name: 'company', +} + +export const FMPCalendarIpoDataSchema = CalendarIpoDataSchema.extend({ + name: z.string().nullable().default(null).describe('The name of the entity going public.'), + exchange: z.string().nullable().default(null).describe('The exchange where the IPO is listed.'), + actions: z.string().nullable().default(null).describe('Actions related to the IPO.'), + shares: z.number().nullable().default(null).describe('The number of shares being offered in the IPO.'), + price_range: z.string().nullable().default(null).describe('The expected price range for the IPO shares.'), + market_cap: z.number().nullable().default(null).describe('The estimated market capitalization at IPO time.'), +}).passthrough() + +export type FMPCalendarIpoData = z.infer + +// --- Fetcher --- + +export class FMPCalendarIpoFetcher extends Fetcher { + static override transformQuery(params: Record): FMPCalendarIpoQueryParams { + return FMPCalendarIpoQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPCalendarIpoQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const now = new Date() + const startDate = query.start_date ?? now.toISOString().slice(0, 10) + const endDate = query.end_date ?? new Date(now.getTime() + 3 * 86400000).toISOString().slice(0, 10) + + const url = 'https://financialmodelingprep.com/stable/ipos-calendar' + + `?from=${startDate}&to=${endDate}&apikey=${apiKey}` + return getDataMany(url) + } + + static override transformData( + query: FMPCalendarIpoQueryParams, + data: Record[], + ): FMPCalendarIpoData[] { + const sorted = [...data].sort((a, b) => + String(b.date ?? '').localeCompare(String(a.date ?? '')), + ) + return sorted.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPCalendarIpoDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/calendar-splits.ts b/packages/opentypebb/src/providers/fmp/models/calendar-splits.ts new file mode 100644 index 00000000..08a93535 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/calendar-splits.ts @@ -0,0 +1,48 @@ +/** + * FMP Calendar Splits Model. + * Maps to: openbb_fmp/models/calendar_splits.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CalendarSplitsQueryParamsSchema, CalendarSplitsDataSchema } from '../../../standard-models/calendar-splits.js' +import { getDataMany } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPCalendarSplitsQueryParamsSchema = CalendarSplitsQueryParamsSchema.extend({}) +export type FMPCalendarSplitsQueryParams = z.infer + +// --- Data --- + +export const FMPCalendarSplitsDataSchema = CalendarSplitsDataSchema.extend({}).passthrough() +export type FMPCalendarSplitsData = z.infer + +// --- Fetcher --- + +export class FMPCalendarSplitsFetcher extends Fetcher { + static override transformQuery(params: Record): FMPCalendarSplitsQueryParams { + return FMPCalendarSplitsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPCalendarSplitsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const now = new Date() + const startDate = query.start_date ?? new Date(now.getTime() - 7 * 86400000).toISOString().slice(0, 10) + const endDate = query.end_date ?? new Date(now.getTime() + 14 * 86400000).toISOString().slice(0, 10) + + const url = 'https://financialmodelingprep.com/stable/splits-calendar' + + `?from=${startDate}&to=${endDate}&apikey=${apiKey}` + return getDataMany(url) + } + + static override transformData( + query: FMPCalendarSplitsQueryParams, + data: Record[], + ): FMPCalendarSplitsData[] { + return data.map(d => FMPCalendarSplitsDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/cash-flow-growth.ts b/packages/opentypebb/src/providers/fmp/models/cash-flow-growth.ts new file mode 100644 index 00000000..e9eab5de --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/cash-flow-growth.ts @@ -0,0 +1,121 @@ +/** + * FMP Cash Flow Statement Growth Model. + * Maps to: openbb_fmp/models/cash_flow_growth.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CashFlowStatementGrowthQueryParamsSchema, CashFlowStatementGrowthDataSchema } from '../../../standard-models/cash-flow-growth.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPCashFlowStatementGrowthQueryParamsSchema = CashFlowStatementGrowthQueryParamsSchema.extend({ + period: z.enum(['annual', 'quarter']).default('annual').describe('Time period of the data to return.'), +}) + +export type FMPCashFlowStatementGrowthQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + period_ending: 'date', + fiscal_year: 'calendarYear', + fiscal_period: 'period', + reported_currency: 'reportedCurrency', + growth_acquisitions: 'growthAcquisitionsNet', + growth_sale_and_maturity_of_investments: 'growthSalesMaturitiesOfInvestments', + growth_net_cash_from_operating_activities: 'growthNetCashProvidedByOperatingActivites', + growth_other_investing_activities: 'growthOtherInvestingActivites', + growth_net_cash_from_investing_activities: 'growthNetCashUsedForInvestingActivites', + growth_other_financing_activities: 'growthOtherFinancingActivites', + growth_purchase_of_investment_securities: 'growthPurchasesOfInvestments', + growth_account_receivables: 'growthAccountsReceivables', + growth_account_payable: 'growthAccountsPayables', + growth_purchase_of_property_plant_and_equipment: 'growthInvestmentsInPropertyPlantAndEquipment', + growth_repayment_of_debt: 'growthDebtRepayment', + growth_net_change_in_cash_and_equivalents: 'growthNetChangeInCash', + growth_effect_of_exchange_rate_changes_on_cash: 'growthEffectOfForexChangesOnCash', + growth_net_cash_from_financing_activities: 'growthNetCashUsedProvidedByFinancingActivities', + growth_net_equity_issuance: 'growthNetStockIssuance', + growth_common_equity_issuance: 'growthCommonStockIssued', + growth_common_equity_repurchased: 'growthCommonStockRepurchased', +} + +const pctOrNull = z.number().nullable().default(null) + +export const FMPCashFlowStatementGrowthDataSchema = CashFlowStatementGrowthDataSchema.extend({ + symbol: z.string().nullable().default(null).describe('The stock ticker symbol.'), + reported_currency: z.string().nullable().default(null).describe('The currency in which the financial data is reported.'), + growth_net_income: pctOrNull.describe('Growth rate of net income.'), + growth_depreciation_and_amortization: pctOrNull.describe('Growth rate of depreciation and amortization.'), + growth_deferred_income_tax: pctOrNull.describe('Growth rate of deferred income tax.'), + growth_stock_based_compensation: pctOrNull.describe('Growth rate of stock-based compensation.'), + growth_change_in_working_capital: pctOrNull.describe('Growth rate of change in working capital.'), + growth_account_receivables: pctOrNull.describe('Growth rate of accounts receivables.'), + growth_inventory: pctOrNull.describe('Growth rate of inventory.'), + growth_account_payable: pctOrNull.describe('Growth rate of account payable.'), + growth_other_working_capital: pctOrNull.describe('Growth rate of other working capital.'), + growth_other_non_cash_items: pctOrNull.describe('Growth rate of other non-cash items.'), + growth_net_cash_from_operating_activities: pctOrNull.describe('Growth rate of net cash provided by operating activities.'), + growth_purchase_of_property_plant_and_equipment: pctOrNull.describe('Growth rate of investments in property, plant, and equipment.'), + growth_acquisitions: pctOrNull.describe('Growth rate of net acquisitions.'), + growth_purchase_of_investment_securities: pctOrNull.describe('Growth rate of purchases of investments.'), + growth_sale_and_maturity_of_investments: pctOrNull.describe('Growth rate of sales maturities of investments.'), + growth_other_investing_activities: pctOrNull.describe('Growth rate of other investing activities.'), + growth_net_cash_from_investing_activities: pctOrNull.describe('Growth rate of net cash used for investing activities.'), + growth_short_term_net_debt_issuance: pctOrNull.describe('Growth rate of short term net debt issuance.'), + growth_long_term_net_debt_issuance: pctOrNull.describe('Growth rate of long term net debt issuance.'), + growth_net_debt_issuance: pctOrNull.describe('Growth rate of net debt issuance.'), + growth_repayment_of_debt: pctOrNull.describe('Growth rate of debt repayment.'), + growth_common_equity_issuance: pctOrNull.describe('Growth rate of common equity issued.'), + growth_common_equity_repurchased: pctOrNull.describe('Growth rate of common equity repurchased.'), + growth_net_equity_issuance: pctOrNull.describe('Growth rate of net equity issuance.'), + growth_dividends_paid: pctOrNull.describe('Growth rate of dividends paid.'), + growth_preferred_dividends_paid: pctOrNull.describe('Growth rate of preferred dividends paid.'), + growth_other_financing_activities: pctOrNull.describe('Growth rate of other financing activities.'), + growth_net_cash_from_financing_activities: pctOrNull.describe('Growth rate of net cash used/provided by financing activities.'), + growth_effect_of_exchange_rate_changes_on_cash: pctOrNull.describe('Growth rate of the effect of foreign exchange changes on cash.'), + growth_net_change_in_cash_and_equivalents: pctOrNull.describe('Growth rate of net change in cash.'), + growth_cash_at_beginning_of_period: pctOrNull.describe('Growth rate of cash at the beginning of the period.'), + growth_cash_at_end_of_period: pctOrNull.describe('Growth rate of cash at the end of the period.'), + growth_operating_cash_flow: pctOrNull.describe('Growth rate of operating cash flow.'), + growth_capital_expenditure: pctOrNull.describe('Growth rate of capital expenditure.'), + growth_income_taxes_paid: pctOrNull.describe('Growth rate of income taxes paid.'), + growth_interest_paid: pctOrNull.describe('Growth rate of interest paid.'), + growth_free_cash_flow: pctOrNull.describe('Growth rate of free cash flow.'), +}).passthrough() + +export type FMPCashFlowStatementGrowthData = z.infer + +// --- Fetcher --- + +export class FMPCashFlowStatementGrowthFetcher extends Fetcher { + static override transformQuery(params: Record): FMPCashFlowStatementGrowthQueryParams { + return FMPCashFlowStatementGrowthQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPCashFlowStatementGrowthQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const url = 'https://financialmodelingprep.com/stable/cash-flow-statement-growth' + + `?symbol=${query.symbol}` + + `&period=${query.period}` + + `&limit=${query.limit ?? 5}` + + `&apikey=${apiKey}` + return getDataMany(url) + } + + static override transformData( + query: FMPCashFlowStatementGrowthQueryParams, + data: Record[], + ): FMPCashFlowStatementGrowthData[] { + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPCashFlowStatementGrowthDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/cash-flow.ts b/packages/opentypebb/src/providers/fmp/models/cash-flow.ts new file mode 100644 index 00000000..a8f5f29a --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/cash-flow.ts @@ -0,0 +1,143 @@ +/** + * FMP Cash Flow Statement Model. + * Maps to: openbb_fmp/models/cash_flow.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CashFlowStatementQueryParamsSchema, CashFlowStatementDataSchema } from '../../../standard-models/cash-flow.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPCashFlowStatementQueryParamsSchema = CashFlowStatementQueryParamsSchema.extend({ + period: z.enum(['q1', 'q2', 'q3', 'q4', 'fy', 'ttm', 'annual', 'quarter']).default('annual').describe('Time period of the data to return.'), +}) + +export type FMPCashFlowStatementQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + period_ending: 'date', + fiscal_period: 'period', + fiscal_year: 'calendarYear', + filing_date: 'fillingDate', + accepted_date: 'acceptedDate', + reported_currency: 'reportedCurrency', + net_income: 'netIncome', + depreciation_and_amortization: 'depreciationAndAmortization', + deferred_income_tax: 'deferredIncomeTax', + stock_based_compensation: 'stockBasedCompensation', + change_in_working_capital: 'changeInWorkingCapital', + change_in_account_receivables: 'accountsReceivables', + change_in_inventory: 'inventory', + change_in_account_payable: 'accountsPayables', + change_in_other_working_capital: 'otherWorkingCapital', + change_in_other_non_cash_items: 'otherNonCashItems', + net_cash_from_operating_activities: 'netCashProvidedByOperatingActivities', + purchase_of_property_plant_and_equipment: 'investmentsInPropertyPlantAndEquipment', + acquisitions: 'acquisitionsNet', + purchase_of_investment_securities: 'purchasesOfInvestments', + sale_and_maturity_of_investments: 'salesMaturitiesOfInvestments', + other_investing_activities: 'otherInvestingActivities', + net_cash_from_investing_activities: 'netCashProvidedByInvestingActivities', + repayment_of_debt: 'debtRepayment', + issuance_of_common_equity: 'commonStockIssuance', + repurchase_of_common_equity: 'commonStockRepurchased', + net_common_equity_issuance: 'netCommonStockIssuance', + net_preferred_equity_issuance: 'netPreferredStockIssuance', + net_equity_issuance: 'netStockIssuance', + payment_of_dividends: 'dividendsPaid', + other_financing_activities: 'otherFinancingActivites', + net_cash_from_financing_activities: 'netCashProvidedByFinancingActivities', + effect_of_exchange_rate_changes_on_cash: 'effectOfForexChangesOnCash', + net_change_in_cash_and_equivalents: 'netChangeInCash', + cash_at_beginning_of_period: 'cashAtBeginningOfPeriod', + cash_at_end_of_period: 'cashAtEndOfPeriod', + operating_cash_flow: 'operatingCashFlow', + capital_expenditure: 'capitalExpenditure', + free_cash_flow: 'freeCashFlow', +} + +const intOrNull = z.number().int().nullable().default(null) + +export const FMPCashFlowStatementDataSchema = CashFlowStatementDataSchema.extend({ + fiscal_year: z.number().int().nullable().default(null).describe('The fiscal year of the fiscal period.'), + filing_date: z.string().nullable().default(null).describe('The date of the filing.'), + accepted_date: z.string().nullable().default(null).describe('The date the filing was accepted.'), + cik: z.string().nullable().default(null).describe('The Central Index Key (CIK) assigned by the SEC.'), + symbol: z.string().nullable().default(null).describe('The stock ticker symbol.'), + reported_currency: z.string().nullable().default(null).describe('The currency in which the cash flow statement was reported.'), + net_income: intOrNull.describe('Net income.'), + depreciation_and_amortization: intOrNull.describe('Depreciation and amortization.'), + deferred_income_tax: intOrNull.describe('Deferred income tax.'), + stock_based_compensation: intOrNull.describe('Stock-based compensation.'), + change_in_working_capital: intOrNull.describe('Change in working capital.'), + change_in_account_receivables: intOrNull.describe('Change in account receivables.'), + change_in_inventory: intOrNull.describe('Change in inventory.'), + change_in_account_payable: intOrNull.describe('Change in account payable.'), + change_in_other_working_capital: intOrNull.describe('Change in other working capital.'), + change_in_other_non_cash_items: intOrNull.describe('Change in other non-cash items.'), + net_cash_from_operating_activities: intOrNull.describe('Net cash from operating activities.'), + purchase_of_property_plant_and_equipment: intOrNull.describe('Purchase of property, plant and equipment.'), + acquisitions: intOrNull.describe('Acquisitions.'), + purchase_of_investment_securities: intOrNull.describe('Purchase of investment securities.'), + sale_and_maturity_of_investments: intOrNull.describe('Sale and maturity of investments.'), + other_investing_activities: intOrNull.describe('Other investing activities.'), + net_cash_from_investing_activities: intOrNull.describe('Net cash from investing activities.'), + repayment_of_debt: intOrNull.describe('Repayment of debt.'), + issuance_of_common_equity: intOrNull.describe('Issuance of common equity.'), + repurchase_of_common_equity: intOrNull.describe('Repurchase of common equity.'), + payment_of_dividends: intOrNull.describe('Payment of dividends.'), + other_financing_activities: intOrNull.describe('Other financing activities.'), + net_cash_from_financing_activities: intOrNull.describe('Net cash from financing activities.'), + effect_of_exchange_rate_changes_on_cash: intOrNull.describe('Effect of exchange rate changes on cash.'), + net_change_in_cash_and_equivalents: intOrNull.describe('Net change in cash and equivalents.'), + cash_at_beginning_of_period: intOrNull.describe('Cash at beginning of period.'), + cash_at_end_of_period: intOrNull.describe('Cash at end of period.'), + operating_cash_flow: intOrNull.describe('Operating cash flow.'), + capital_expenditure: intOrNull.describe('Capital expenditure.'), + free_cash_flow: intOrNull.describe('Free cash flow.'), +}).passthrough() + +export type FMPCashFlowStatementData = z.infer + +// --- Fetcher --- + +export class FMPCashFlowStatementFetcher extends Fetcher { + static override transformQuery(params: Record): FMPCashFlowStatementQueryParams { + return FMPCashFlowStatementQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPCashFlowStatementQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + let baseUrl = 'https://financialmodelingprep.com/stable/cash-flow-statement' + + if (query.period === 'ttm') { + baseUrl += '-ttm' + } + + const url = baseUrl + + `?symbol=${query.symbol}` + + (query.period !== 'ttm' ? `&period=${query.period}` : '') + + `&limit=${query.limit ?? 5}` + + `&apikey=${apiKey}` + + return getDataMany(url) + } + + static override transformData( + query: FMPCashFlowStatementQueryParams, + data: Record[], + ): FMPCashFlowStatementData[] { + return data.map((d) => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPCashFlowStatementDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/company-filings.ts b/packages/opentypebb/src/providers/fmp/models/company-filings.ts new file mode 100644 index 00000000..2c9945e5 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/company-filings.ts @@ -0,0 +1,79 @@ +/** + * FMP Company Filings Model. + * Maps to: openbb_fmp/models/company_filings.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CompanyFilingsQueryParamsSchema, CompanyFilingsDataSchema } from '../../../standard-models/company-filings.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + accepted_date: 'acceptedDate', + report_type: 'formType', + filing_url: 'link', + report_url: 'finalLink', +} + +export const FMPCompanyFilingsQueryParamsSchema = CompanyFilingsQueryParamsSchema.extend({ + cik: z.string().nullable().default(null).describe('CIK number.'), + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), + limit: z.coerce.number().default(1000).describe('The number of data entries to return (max 1000).'), + page: z.coerce.number().default(0).describe('Page number for pagination.'), +}) +export type FMPCompanyFilingsQueryParams = z.infer + +export const FMPCompanyFilingsDataSchema = CompanyFilingsDataSchema.extend({ + filing_url: z.string().nullable().default(null).describe('URL to the filing document.'), + symbol: z.string().nullable().default(null).describe('Symbol.'), + cik: z.string().nullable().default(null).describe('CIK number.'), + accepted_date: z.string().nullable().default(null).describe('Date the filing was accepted.'), +}).passthrough() +export type FMPCompanyFilingsData = z.infer + +export class FMPCompanyFilingsFetcher extends Fetcher { + static override transformQuery(params: Record): FMPCompanyFilingsQueryParams { + return FMPCompanyFilingsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPCompanyFilingsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const qs = new URLSearchParams() + qs.set('apikey', apiKey) + qs.set('limit', String(Math.min(query.limit, 1000))) + qs.set('page', String(query.page)) + + if (query.start_date) qs.set('from', query.start_date) + if (query.end_date) qs.set('to', query.end_date) + + let endpoint: string + if (query.symbol) { + qs.set('symbol', query.symbol) + endpoint = 'sec-filings-search/symbol' + } else if (query.cik) { + qs.set('cik', query.cik) + endpoint = 'sec-filings-search/cik' + } else { + endpoint = 'sec-filings-search/symbol' + } + + return getDataMany( + `https://financialmodelingprep.com/stable/${endpoint}?${qs.toString()}`, + ) + } + + static override transformData( + _query: FMPCompanyFilingsQueryParams, + data: Record[], + ): FMPCompanyFilingsData[] { + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPCompanyFilingsDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/company-news.ts b/packages/opentypebb/src/providers/fmp/models/company-news.ts new file mode 100644 index 00000000..074fb0cc --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/company-news.ts @@ -0,0 +1,91 @@ +/** + * FMP Company News Model. + * Maps to: openbb_fmp/models/company_news.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CompanyNewsQueryParamsSchema, CompanyNewsDataSchema } from '../../../standard-models/company-news.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getDataMany } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPCompanyNewsQueryParamsSchema = CompanyNewsQueryParamsSchema.extend({ + page: z.number().int().min(0).max(100).default(0).describe('Page number of the results.'), + press_release: z.boolean().nullable().default(null).describe('When true, return only press releases.'), +}) + +export type FMPCompanyNewsQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + symbols: 'symbol', + date: 'publishedDate', + author: 'publisher', + images: 'image', + source: 'site', + excerpt: 'text', +} + +export const FMPCompanyNewsDataSchema = CompanyNewsDataSchema.extend({ + source: z.string().describe('Name of the news site.'), +}).passthrough() + +export type FMPCompanyNewsData = z.infer + +// --- Fetcher --- + +export class FMPCompanyNewsFetcher extends Fetcher { + static override transformQuery(params: Record): FMPCompanyNewsQueryParams { + if (!params.symbol) { + throw new Error('Required field missing -> symbol') + } + return FMPCompanyNewsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPCompanyNewsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const limit = query.limit ?? 250 + const page = query.page ?? 0 + let baseUrl = 'https://financialmodelingprep.com/stable/news/' + + if (query.press_release) { + baseUrl += 'press-releases?' + } else { + baseUrl += 'stock?' + } + + let url = baseUrl + `symbols=${query.symbol}` + + if (query.start_date) url += `&from=${query.start_date}` + if (query.end_date) url += `&to=${query.end_date}` + + url += `&limit=${limit}&page=${page}&apikey=${apiKey}` + + const response = await getDataMany(url) + + if (!response || response.length === 0) { + throw new EmptyDataError() + } + + return response.sort((a, b) => + String(b.publishedDate ?? '').localeCompare(String(a.publishedDate ?? '')), + ) + } + + static override transformData( + query: FMPCompanyNewsQueryParams, + data: Record[], + ): FMPCompanyNewsData[] { + return data.map((d) => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPCompanyNewsDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/crypto-historical.ts b/packages/opentypebb/src/providers/fmp/models/crypto-historical.ts new file mode 100644 index 00000000..765a2015 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/crypto-historical.ts @@ -0,0 +1,60 @@ +/** + * FMP Crypto Historical Price Model. + * Maps to: openbb_fmp/models/crypto_historical.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CryptoHistoricalQueryParamsSchema, CryptoHistoricalDataSchema } from '../../../standard-models/crypto-historical.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getHistoricalOhlc } from '../utils/helpers.js' + +export const FMPCryptoHistoricalQueryParamsSchema = CryptoHistoricalQueryParamsSchema.extend({ + interval: z.enum(['1m', '5m', '1h', '1d']).default('1d').describe('Time interval of the data.'), +}) +export type FMPCryptoHistoricalQueryParams = z.infer + +const ALIAS_DICT: Record = { change_percent: 'changeOverTime' } + +export const FMPCryptoHistoricalDataSchema = CryptoHistoricalDataSchema.extend({ + change: z.number().nullable().default(null).describe('Change in the price from the previous close.'), + change_percent: z.number().nullable().default(null).describe('Change in the price from the previous close, as a normalized percent.'), +}).passthrough() +export type FMPCryptoHistoricalData = z.infer + +export class FMPCryptoHistoricalFetcher extends Fetcher { + static override transformQuery(params: Record): FMPCryptoHistoricalQueryParams { + const now = new Date() + const oneYearAgo = new Date(now) + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) + if (params.start_date == null) params.start_date = oneYearAgo.toISOString().split('T')[0] + if (params.end_date == null) params.end_date = now.toISOString().split('T')[0] + return FMPCryptoHistoricalQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPCryptoHistoricalQueryParams, + credentials: Record | null, + ): Promise[]> { + return getHistoricalOhlc(query, credentials) + } + + static override transformData( + query: FMPCryptoHistoricalQueryParams, + data: Record[], + ): FMPCryptoHistoricalData[] { + const multiSymbol = query.symbol.split(',').length > 1 + const sorted = [...data].sort((a, b) => { + if (multiSymbol) { + const dc = String(a.date ?? '').localeCompare(String(b.date ?? '')) + return dc !== 0 ? dc : String(a.symbol ?? '').localeCompare(String(b.symbol ?? '')) + } + return String(a.date ?? '').localeCompare(String(b.date ?? '')) + }) + return sorted.map((d) => { + const aliased = applyAliases(d, ALIAS_DICT) + if (typeof aliased.change_percent === 'number') aliased.change_percent = aliased.change_percent / 100 + return FMPCryptoHistoricalDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/crypto-search.ts b/packages/opentypebb/src/providers/fmp/models/crypto-search.ts new file mode 100644 index 00000000..cfc0a3a2 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/crypto-search.ts @@ -0,0 +1,51 @@ +/** + * FMP Crypto Search Model. + * Maps to: openbb_fmp/models/crypto_search.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CryptoSearchQueryParamsSchema, CryptoSearchDataSchema } from '../../../standard-models/crypto-search.js' +import { getDataMany } from '../utils/helpers.js' + +export const FMPCryptoSearchQueryParamsSchema = CryptoSearchQueryParamsSchema +export type FMPCryptoSearchQueryParams = z.infer + +export const FMPCryptoSearchDataSchema = CryptoSearchDataSchema.extend({ + exchange: z.string().nullable().default(null).describe('The exchange code the crypto trades on.'), +}).passthrough() +export type FMPCryptoSearchData = z.infer + +export class FMPCryptoSearchFetcher extends Fetcher { + static override transformQuery(params: Record): FMPCryptoSearchQueryParams { + // Remove dashes from query + if (typeof params.query === 'string') { + params.query = params.query.replace(/-/g, '') + } + return FMPCryptoSearchQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPCryptoSearchQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + return getDataMany(`https://financialmodelingprep.com/stable/cryptocurrency-list?apikey=${apiKey}`) + } + + static override transformData( + query: FMPCryptoSearchQueryParams, + data: Record[], + ): FMPCryptoSearchData[] { + let filtered = data + if (query.query) { + const q = query.query.toLowerCase() + filtered = data.filter((d) => + String(d.symbol ?? '').toLowerCase().includes(q) || + String(d.name ?? '').toLowerCase().includes(q) || + String(d.exchange ?? '').toLowerCase().includes(q), + ) + } + return filtered.map((d) => FMPCryptoSearchDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/currency-historical.ts b/packages/opentypebb/src/providers/fmp/models/currency-historical.ts new file mode 100644 index 00000000..62f6c73d --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/currency-historical.ts @@ -0,0 +1,57 @@ +/** + * FMP Currency Historical Price Model. + * Maps to: openbb_fmp/models/currency_historical.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CurrencyHistoricalQueryParamsSchema, CurrencyHistoricalDataSchema } from '../../../standard-models/currency-historical.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getHistoricalOhlc } from '../utils/helpers.js' + +export const FMPCurrencyHistoricalQueryParamsSchema = CurrencyHistoricalQueryParamsSchema.extend({ + interval: z.enum(['1m', '5m', '1h', '1d']).default('1d').describe('Time interval of the data.'), +}) +export type FMPCurrencyHistoricalQueryParams = z.infer + +export const FMPCurrencyHistoricalDataSchema = CurrencyHistoricalDataSchema.extend({ + change: z.number().nullable().default(null).describe('Change in the price from the previous close.'), + change_percent: z.number().nullable().default(null).describe('Percent change in the price from the previous close.'), +}).passthrough() +export type FMPCurrencyHistoricalData = z.infer + +export class FMPCurrencyHistoricalFetcher extends Fetcher { + static override transformQuery(params: Record): FMPCurrencyHistoricalQueryParams { + const now = new Date() + const oneYearAgo = new Date(now) + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) + if (params.start_date == null) params.start_date = oneYearAgo.toISOString().split('T')[0] + if (params.end_date == null) params.end_date = now.toISOString().split('T')[0] + return FMPCurrencyHistoricalQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPCurrencyHistoricalQueryParams, + credentials: Record | null, + ): Promise[]> { + return getHistoricalOhlc(query, credentials) + } + + static override transformData( + query: FMPCurrencyHistoricalQueryParams, + data: Record[], + ): FMPCurrencyHistoricalData[] { + const multiSymbol = query.symbol.split(',').length > 1 + const sorted = [...data].sort((a, b) => { + if (multiSymbol) { + const dc = String(a.date ?? '').localeCompare(String(b.date ?? '')) + return dc !== 0 ? dc : String(a.symbol ?? '').localeCompare(String(b.symbol ?? '')) + } + return String(a.date ?? '').localeCompare(String(b.date ?? '')) + }) + return sorted.map((d) => { + if (typeof d.change_percent === 'number') d.change_percent = d.change_percent / 100 + return FMPCurrencyHistoricalDataSchema.parse(d) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/currency-pairs.ts b/packages/opentypebb/src/providers/fmp/models/currency-pairs.ts new file mode 100644 index 00000000..50507958 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/currency-pairs.ts @@ -0,0 +1,62 @@ +/** + * FMP Currency Available Pairs Model. + * Maps to: openbb_fmp/models/currency_pairs.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CurrencyPairsQueryParamsSchema, CurrencyPairsDataSchema } from '../../../standard-models/currency-pairs.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getDataMany } from '../utils/helpers.js' + +export const FMPCurrencyPairsQueryParamsSchema = CurrencyPairsQueryParamsSchema +export type FMPCurrencyPairsQueryParams = z.infer + +export const FMPCurrencyPairsDataSchema = CurrencyPairsDataSchema.extend({ + from_currency: z.string().describe('Base currency of the currency pair.'), + to_currency: z.string().describe('Quote currency of the currency pair.'), + from_name: z.string().describe('Name of the base currency.'), + to_name: z.string().describe('Name of the quote currency.'), +}).passthrough() +export type FMPCurrencyPairsData = z.infer + +export class FMPCurrencyPairsFetcher extends Fetcher { + static override transformQuery(params: Record): FMPCurrencyPairsQueryParams { + return FMPCurrencyPairsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPCurrencyPairsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + return getDataMany(`https://financialmodelingprep.com/stable/forex-list?apikey=${apiKey}`) + } + + static override transformData( + query: FMPCurrencyPairsQueryParams, + data: Record[], + ): FMPCurrencyPairsData[] { + if (!data || data.length === 0) { + throw new EmptyDataError('The request was returned empty.') + } + + let filtered = data + if (query.query) { + const q = query.query.toLowerCase() + filtered = data.filter((d) => + String(d.symbol ?? '').toLowerCase().includes(q) || + String(d.fromCurrency ?? '').toLowerCase().includes(q) || + String(d.toCurrency ?? '').toLowerCase().includes(q) || + String(d.fromName ?? '').toLowerCase().includes(q) || + String(d.toName ?? '').toLowerCase().includes(q), + ) + } + + if (filtered.length === 0) { + throw new EmptyDataError(`No results were found with the query supplied. -> ${query.query}`) + } + + return filtered.map((d) => FMPCurrencyPairsDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/currency-snapshots.ts b/packages/opentypebb/src/providers/fmp/models/currency-snapshots.ts new file mode 100644 index 00000000..daf69e70 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/currency-snapshots.ts @@ -0,0 +1,135 @@ +/** + * FMP Currency Snapshots Model. + * Maps to: openbb_fmp/models/currency_snapshots.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CurrencySnapshotsQueryParamsSchema, CurrencySnapshotsDataSchema } from '../../../standard-models/currency-snapshots.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' + +const ALIAS_DICT: Record = { + last_rate: 'price', + high: 'dayHigh', + low: 'dayLow', + ma50: 'priceAvg50', + ma200: 'priceAvg200', + year_high: 'yearHigh', + year_low: 'yearLow', + prev_close: 'previousClose', + change_percent: 'changePercentage', + last_rate_timestamp: 'timestamp', +} + +const numOrNull = z.number().nullable().default(null) + +export const FMPCurrencySnapshotsQueryParamsSchema = CurrencySnapshotsQueryParamsSchema +export type FMPCurrencySnapshotsQueryParams = z.infer + +export const FMPCurrencySnapshotsDataSchema = CurrencySnapshotsDataSchema.extend({ + change: numOrNull.describe('The change in the price from the previous close.'), + change_percent: numOrNull.describe('The change percent from the previous close.'), + ma50: numOrNull.describe('The 50-day moving average.'), + ma200: numOrNull.describe('The 200-day moving average.'), + year_high: numOrNull.describe('The 52-week high.'), + year_low: numOrNull.describe('The 52-week low.'), + last_rate_timestamp: z.string().nullable().default(null).describe('The timestamp of the last rate.'), +}).passthrough() +export type FMPCurrencySnapshotsData = z.infer + +export class FMPCurrencySnapshotsFetcher extends Fetcher { + static override transformQuery(params: Record): FMPCurrencySnapshotsQueryParams { + // Uppercase the base + if (typeof params.base === 'string') params.base = params.base.toUpperCase() + if (typeof params.counter_currencies === 'string') { + params.counter_currencies = params.counter_currencies.toUpperCase() + } + return FMPCurrencySnapshotsQueryParamsSchema.parse(params) + } + + static override async extractData( + _query: FMPCurrencySnapshotsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + return getDataMany( + `https://financialmodelingprep.com/stable/batch-forex-quotes?short=false&apikey=${apiKey}`, + ) + } + + static override transformData( + query: FMPCurrencySnapshotsQueryParams, + data: Record[], + ): FMPCurrencySnapshotsData[] { + if (!data || data.length === 0) { + throw new EmptyDataError('No data was returned from the FMP endpoint.') + } + + const results: FMPCurrencySnapshotsData[] = [] + const bases = query.base.toUpperCase().split(',') + const counterCurrencies = query.counter_currencies + ? query.counter_currencies.toUpperCase().split(',') + : null + + for (const base of bases) { + for (const d of data) { + const symbol = String(d.symbol ?? '') + const name = String(d.name ?? '') + + // Check if this pair matches + let isMatch = false + let baseCurrency = base + let counterCurrency = '' + + if (query.quote_type === 'indirect') { + // Indirect: base currency is on the left (e.g., USD/EUR -> looking for USD as base) + if (symbol.startsWith(base)) { + isMatch = true + const parts = name.split('/') + counterCurrency = parts.length > 1 ? parts[1].trim() : symbol.replace(base, '') + } + } else { + // Direct: base currency is on the right (e.g., EUR/USD -> looking for USD as base) + if (symbol.endsWith(base)) { + isMatch = true + const parts = name.split('/') + counterCurrency = parts.length > 0 ? parts[0].trim() : symbol.replace(base, '') + } + } + + if (!isMatch) continue + + // Filter counter currencies if specified + if (counterCurrencies && !counterCurrencies.includes(counterCurrency)) continue + + // Normalize change_percent + const entry = { ...d } + if (typeof entry.changePercentage === 'number') { + entry.changePercentage = entry.changePercentage / 100 + } + // Convert Unix timestamp to ISO string + if (typeof entry.timestamp === 'number') { + entry.timestamp = new Date((entry.timestamp as number) * 1000).toISOString() + } + + const aliased = applyAliases(entry, ALIAS_DICT) + aliased.base_currency = baseCurrency + aliased.counter_currency = counterCurrency + + try { + results.push(FMPCurrencySnapshotsDataSchema.parse(aliased)) + } catch { + // Skip entries that fail validation + } + } + } + + if (results.length === 0) { + throw new EmptyDataError('No data was found using the applied filters. Check the parameters.') + } + + return results + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/discovery-filings.ts b/packages/opentypebb/src/providers/fmp/models/discovery-filings.ts new file mode 100644 index 00000000..d10bc57d --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/discovery-filings.ts @@ -0,0 +1,81 @@ +/** + * FMP Discovery Filings Model. + * Maps to: openbb_fmp/models/discovery_filings.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { DiscoveryFilingsQueryParamsSchema, DiscoveryFilingsDataSchema } from '../../../standard-models/discovery-filings.js' +import { getDataMany } from '../utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' + +export const FMPDiscoveryFilingsQueryParamsSchema = DiscoveryFilingsQueryParamsSchema.extend({ + limit: z.coerce.number().nullable().default(null).describe('The maximum number of results to return. Default is 10000.'), +}) +export type FMPDiscoveryFilingsQueryParams = z.infer + +export const FMPDiscoveryFilingsDataSchema = DiscoveryFilingsDataSchema.extend({ + final_link: z.string().nullable().default(null).describe('Direct URL to the main document of the filing.'), +}).passthrough() +export type FMPDiscoveryFilingsData = z.infer + +export class FMPDiscoveryFilingsFetcher extends Fetcher { + static override transformQuery(params: Record): FMPDiscoveryFilingsQueryParams { + return FMPDiscoveryFilingsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPDiscoveryFilingsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const limit = query.limit ?? 10000 + + const now = new Date() + const startDate = query.start_date ?? + new Date(now.getTime() - (query.form_type ? 89 : 2) * 86400000).toISOString().split('T')[0] + const endDate = query.end_date ?? now.toISOString().split('T')[0] + + const baseUrl = query.form_type + ? 'https://financialmodelingprep.com/stable/sec-filings-search/form-type' + : 'https://financialmodelingprep.com/stable/sec-filings-financials/' + + const qs = new URLSearchParams() + qs.set('from', startDate) + qs.set('to', endDate) + if (query.form_type) qs.set('formType', query.form_type) + qs.set('apikey', apiKey) + + // FMP only allows 1000 results per page + const pages = Math.ceil(limit / 1000) + const allResults: Record[] = [] + + for (let page = 0; page < pages; page++) { + try { + const data = await getDataMany( + `${baseUrl}?${qs.toString()}&page=${page}&limit=1000`, + ) + allResults.push(...data) + // If we got fewer than 1000, no more pages + if (data.length < 1000) break + } catch { + // Stop paginating on error (e.g., empty page) + break + } + } + + return allResults.sort((a, b) => + String(b.acceptedDate ?? '').localeCompare(String(a.acceptedDate ?? '')), + ) + } + + static override transformData( + _query: FMPDiscoveryFilingsQueryParams, + data: Record[], + ): FMPDiscoveryFilingsData[] { + if (!data || data.length === 0) { + throw new EmptyDataError('No data was returned for the given query.') + } + return data.map(d => FMPDiscoveryFilingsDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/earnings-call-transcript.ts b/packages/opentypebb/src/providers/fmp/models/earnings-call-transcript.ts new file mode 100644 index 00000000..9a782afd --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/earnings-call-transcript.ts @@ -0,0 +1,95 @@ +/** + * FMP Earnings Call Transcript Model. + * Maps to: openbb_fmp/models/earnings_call_transcript.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EarningsCallTranscriptQueryParamsSchema, EarningsCallTranscriptDataSchema } from '../../../standard-models/earnings-call-transcript.js' +import { getDataMany, getDataOne } from '../utils/helpers.js' +import { OpenBBError } from '../../../core/provider/utils/errors.js' + +const ALIAS_DICT: Record = { + quarter: 'period', +} + +export const FMPEarningsCallTranscriptQueryParamsSchema = EarningsCallTranscriptQueryParamsSchema +export type FMPEarningsCallTranscriptQueryParams = z.infer + +export const FMPEarningsCallTranscriptDataSchema = EarningsCallTranscriptDataSchema +export type FMPEarningsCallTranscriptData = z.infer + +export class FMPEarningsCallTranscriptFetcher extends Fetcher { + static override transformQuery(params: Record): FMPEarningsCallTranscriptQueryParams { + return FMPEarningsCallTranscriptQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPEarningsCallTranscriptQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const symbol = query.symbol.toUpperCase() + + // Get available transcript dates for the symbol + let transcriptDates: Record[] + try { + transcriptDates = await getDataMany( + `https://financialmodelingprep.com/stable/earning-call-transcript-dates?symbol=${symbol}&apikey=${apiKey}`, + ) + } catch { + throw new OpenBBError(`No transcripts found for symbol ${symbol}.`) + } + + if (!transcriptDates || transcriptDates.length === 0) { + throw new OpenBBError(`No transcripts found for symbol ${symbol}.`) + } + + // Sort by date descending + transcriptDates.sort((a, b) => String(b.date ?? '').localeCompare(String(a.date ?? ''))) + + // Determine year and quarter + let year = query.year ?? (transcriptDates[0].fiscalYear as number) + let quarter = query.quarter ?? (transcriptDates[0].quarter as number) + + // Validate year exists + const validYears = transcriptDates.map(t => t.fiscalYear) + if (!validYears.includes(year)) { + year = transcriptDates[0].fiscalYear as number + } + + // Validate quarter exists for the year + const yearTranscripts = transcriptDates.filter(t => t.fiscalYear === year) + const validQuarters = yearTranscripts.map(t => t.quarter) + if (!validQuarters.includes(quarter)) { + quarter = yearTranscripts[0]?.quarter as number ?? 1 + } + + const url = `https://financialmodelingprep.com/stable/earning-call-transcript?symbol=${symbol}&year=${year}&quarter=${quarter}&apikey=${apiKey}` + + try { + const result = await getDataOne(url) + return [result] + } catch { + throw new OpenBBError(`No transcript found for ${symbol} in ${year} Q${quarter}`) + } + } + + static override transformData( + _query: FMPEarningsCallTranscriptQueryParams, + data: Record[], + ): FMPEarningsCallTranscriptData[] { + if (!data || data.length === 0) { + throw new OpenBBError('No data found.') + } + + return data.map(d => { + // Apply alias: period -> quarter + if (d.period != null && d.quarter == null) { + d.quarter = d.period + delete d.period + } + return FMPEarningsCallTranscriptDataSchema.parse(d) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/economic-calendar.ts b/packages/opentypebb/src/providers/fmp/models/economic-calendar.ts new file mode 100644 index 00000000..9a98a1cb --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/economic-calendar.ts @@ -0,0 +1,75 @@ +/** + * FMP Economic Calendar Model. + * Maps to: openbb_fmp/models/economic_calendar.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EconomicCalendarQueryParamsSchema, EconomicCalendarDataSchema } from '../../../standard-models/economic-calendar.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPEconomicCalendarQueryParamsSchema = EconomicCalendarQueryParamsSchema.extend({}) +export type FMPEconomicCalendarQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + consensus: 'estimate', + importance: 'impact', + last_updated: 'updatedAt', + created_at: 'createdAt', + change_percent: 'changePercentage', +} + +export const FMPEconomicCalendarDataSchema = EconomicCalendarDataSchema.extend({ + change: z.number().nullable().default(null).describe('Value change since previous.'), + change_percent: z.number().nullable().default(null).describe('Percentage change since previous.'), + last_updated: z.string().nullable().default(null).describe('Last updated timestamp.'), + created_at: z.string().nullable().default(null).describe('Created timestamp.'), +}).passthrough() + +export type FMPEconomicCalendarData = z.infer + +// --- Fetcher --- + +export class FMPEconomicCalendarFetcher extends Fetcher { + static override transformQuery(params: Record): FMPEconomicCalendarQueryParams { + return FMPEconomicCalendarQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPEconomicCalendarQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const now = new Date() + const startDate = query.start_date ?? new Date(now.getTime() - 1 * 86400000).toISOString().slice(0, 10) + const endDate = query.end_date ?? new Date(now.getTime() + 7 * 86400000).toISOString().slice(0, 10) + + const url = 'https://financialmodelingprep.com/stable/economic-calendar' + + `?from=${startDate}&to=${endDate}&apikey=${apiKey}` + return getDataMany(url) + } + + static override transformData( + query: FMPEconomicCalendarQueryParams, + data: Record[], + ): FMPEconomicCalendarData[] { + return data.map(d => { + // Replace empty strings/zeros with null + const cleaned: Record = {} + for (const [k, v] of Object.entries(d)) { + cleaned[k] = (v === '' || v === 0) ? null : v + } + const aliased = applyAliases(cleaned, ALIAS_DICT) + // Normalize change_percent from percent to decimal + if (typeof aliased.change_percent === 'number') { + aliased.change_percent = aliased.change_percent / 100 + } + return FMPEconomicCalendarDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/equity-historical.ts b/packages/opentypebb/src/providers/fmp/models/equity-historical.ts new file mode 100644 index 00000000..a9851fef --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/equity-historical.ts @@ -0,0 +1,89 @@ +/** + * FMP Equity Historical Price Model. + * Maps to: openbb_fmp/models/equity_historical.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityHistoricalQueryParamsSchema, EquityHistoricalDataSchema } from '../../../standard-models/equity-historical.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getHistoricalOhlc } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPEquityHistoricalQueryParamsSchema = EquityHistoricalQueryParamsSchema.extend({ + interval: z.enum(['1m', '5m', '15m', '30m', '1h', '4h', '1d']).default('1d').describe('Time interval of the data.'), + adjustment: z.enum(['splits_only', 'splits_and_dividends', 'unadjusted']).default('splits_only').describe('Type of adjustment for historical prices. Only applies to daily data.'), +}) + +export type FMPEquityHistoricalQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + open: 'adjOpen', + high: 'adjHigh', + low: 'adjLow', + close: 'adjClose', +} + +export const FMPEquityHistoricalDataSchema = EquityHistoricalDataSchema.extend({ + change: z.number().nullable().default(null).describe('Change in the price from the previous close.'), + change_percent: z.number().nullable().default(null).describe('Change in the price from the previous close, as a normalized percent.'), +}).passthrough() + +export type FMPEquityHistoricalData = z.infer + +// --- Fetcher --- + +export class FMPEquityHistoricalFetcher extends Fetcher { + static override transformQuery(params: Record): FMPEquityHistoricalQueryParams { + const now = new Date() + const oneYearAgo = new Date(now) + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) + + if (params.start_date == null) { + params.start_date = oneYearAgo.toISOString().split('T')[0] + } + if (params.end_date == null) { + params.end_date = now.toISOString().split('T')[0] + } + + return FMPEquityHistoricalQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPEquityHistoricalQueryParams, + credentials: Record | null, + ): Promise[]> { + return getHistoricalOhlc(query, credentials) + } + + static override transformData( + query: FMPEquityHistoricalQueryParams, + data: Record[], + ): FMPEquityHistoricalData[] { + if (!data || data.length === 0) { + throw new EmptyDataError('No data returned from FMP for the given query.') + } + + const multiSymbol = query.symbol.split(',').length > 1 + const sorted = [...data].sort((a, b) => { + if (multiSymbol) { + const dateCompare = String(a.date ?? '').localeCompare(String(b.date ?? '')) + return dateCompare !== 0 ? dateCompare : String(a.symbol ?? '').localeCompare(String(b.symbol ?? '')) + } + return String(a.date ?? '').localeCompare(String(b.date ?? '')) + }) + + return sorted.map((d) => { + const aliased = applyAliases(d, ALIAS_DICT) + // Normalize percent + if (typeof aliased.change_percent === 'number') { + aliased.change_percent = aliased.change_percent / 100 + } + return FMPEquityHistoricalDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/equity-peers.ts b/packages/opentypebb/src/providers/fmp/models/equity-peers.ts new file mode 100644 index 00000000..789ab9a9 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/equity-peers.ts @@ -0,0 +1,53 @@ +/** + * FMP Equity Peers Model. + * Maps to: openbb_fmp/models/equity_peers.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityPeersQueryParamsSchema, EquityPeersDataSchema } from '../../../standard-models/equity-peers.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + name: 'companyName', + market_cap: 'mktCap', +} + +const numOrNull = z.number().nullable().default(null) + +export const FMPEquityPeersQueryParamsSchema = EquityPeersQueryParamsSchema +export type FMPEquityPeersQueryParams = z.infer + +export const FMPEquityPeersDataSchema = EquityPeersDataSchema.extend({ + name: z.string().nullable().default(null).describe('Name of the company.'), + price: numOrNull.describe('Current price.'), + market_cap: numOrNull.describe('Market capitalization.'), +}).passthrough() +export type FMPEquityPeersData = z.infer + +export class FMPEquityPeersFetcher extends Fetcher { + static override transformQuery(params: Record): FMPEquityPeersQueryParams { + return FMPEquityPeersQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPEquityPeersQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + return getDataMany( + `https://financialmodelingprep.com/stable/stock-peers?symbol=${query.symbol}&apikey=${apiKey}`, + ) + } + + static override transformData( + _query: FMPEquityPeersQueryParams, + data: Record[], + ): FMPEquityPeersData[] { + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPEquityPeersDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/equity-profile.ts b/packages/opentypebb/src/providers/fmp/models/equity-profile.ts new file mode 100644 index 00000000..3052c98b --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/equity-profile.ts @@ -0,0 +1,120 @@ +/** + * FMP Equity Profile Model. + * Maps to: openbb_fmp/models/equity_profile.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityInfoQueryParamsSchema, EquityInfoDataSchema } from '../../../standard-models/equity-info.js' +import { applyAliases, replaceEmptyStrings } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' +import { responseCallback } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPEquityProfileQueryParamsSchema = EquityInfoQueryParamsSchema + +export type FMPEquityProfileQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + name: 'companyName', + stock_exchange: 'exchange', + company_url: 'website', + hq_address1: 'address', + hq_address_city: 'city', + hq_address_postal_code: 'zip', + hq_state: 'state', + hq_country: 'country', + business_phone_no: 'phone', + industry_category: 'industry', + employees: 'fullTimeEmployees', + long_description: 'description', + first_stock_price_date: 'ipoDate', + last_price: 'price', + volume_avg: 'averageVolume', + annualized_dividend_amount: 'lastDividend', +} + +export const FMPEquityProfileDataSchema = EquityInfoDataSchema.extend({ + is_etf: z.boolean().describe('If the symbol is an ETF.'), + is_actively_trading: z.boolean().describe('If the company is actively trading.'), + is_adr: z.boolean().describe('If the stock is an ADR.'), + is_fund: z.boolean().describe('If the company is a fund.'), + image: z.string().nullable().default(null).describe('Image of the company.'), + currency: z.string().nullable().default(null).describe('Currency in which the stock is traded.'), + market_cap: z.number().nullable().default(null).describe('Market capitalization of the company.'), + last_price: z.number().nullable().default(null).describe('The last traded price.'), + year_high: z.number().nullable().default(null).describe('The one-year high of the price.'), + year_low: z.number().nullable().default(null).describe('The one-year low of the price.'), + volume_avg: z.number().nullable().default(null).describe('Average daily trading volume.'), + annualized_dividend_amount: z.number().nullable().default(null).describe('The annualized dividend payment based on the most recent regular dividend payment.'), + beta: z.number().nullable().default(null).describe('Beta of the stock relative to the market.'), +}).strip() + +export type FMPEquityProfileData = z.infer + +// --- Fetcher --- + +export class FMPEquityProfileFetcher extends Fetcher { + static override transformQuery(params: Record): FMPEquityProfileQueryParams { + return FMPEquityProfileQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPEquityProfileQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const symbols = query.symbol.split(',') + const baseUrl = 'https://financialmodelingprep.com/stable/' + const results: Record[] = [] + + const getOne = async (symbol: string) => { + const url = `${baseUrl}profile?symbol=${symbol}&apikey=${apiKey}` + try { + const result = await amakeRequest[]>(url, { responseCallback }) + if (result && result.length > 0) { + results.push(result[0]) + } else { + console.warn(`Symbol Error: No data found for ${symbol}`) + } + } catch { + console.warn(`Symbol Error: No data found for ${symbol}`) + } + } + + await Promise.all(symbols.map(getOne)) + + if (results.length === 0) { + throw new EmptyDataError('No data found for the given symbols.') + } + + return results.sort((a, b) => { + const ai = symbols.indexOf(String(a.symbol ?? '')) + const bi = symbols.indexOf(String(b.symbol ?? '')) + return ai - bi + }) + } + + static override transformData( + query: FMPEquityProfileQueryParams, + data: Record[], + ): FMPEquityProfileData[] { + return data.map((d) => { + // Extract year_low and year_high from range + const range = d.range as string | undefined + if (range) { + const [low, high] = range.split('-') + d.year_low = parseFloat(low) || null + d.year_high = parseFloat(high) || null + delete d.range + } + const cleaned = replaceEmptyStrings(d) + const aliased = applyAliases(cleaned, ALIAS_DICT) + return FMPEquityProfileDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/equity-quote.ts b/packages/opentypebb/src/providers/fmp/models/equity-quote.ts new file mode 100644 index 00000000..758519e2 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/equity-quote.ts @@ -0,0 +1,101 @@ +/** + * FMP Equity Quote Model. + * Maps to: openbb_fmp/models/equity_quote.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityQuoteQueryParamsSchema, EquityQuoteDataSchema } from '../../../standard-models/equity-quote.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' +import { responseCallback } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPEquityQuoteQueryParamsSchema = EquityQuoteQueryParamsSchema + +export type FMPEquityQuoteQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + ma50: 'priceAvg50', + ma200: 'priceAvg200', + last_timestamp: 'timestamp', + high: 'dayHigh', + low: 'dayLow', + last_price: 'price', + change_percent: 'changePercentage', + prev_close: 'previousClose', +} + +export const FMPEquityQuoteDataSchema = EquityQuoteDataSchema.extend({ + ma50: z.number().nullable().default(null).describe('50 day moving average price.'), + ma200: z.number().nullable().default(null).describe('200 day moving average price.'), + market_cap: z.number().nullable().default(null).describe('Market cap of the company.'), +}).passthrough() + +export type FMPEquityQuoteData = z.infer + +// --- Fetcher --- + +export class FMPEquityQuoteFetcher extends Fetcher { + static override transformQuery(params: Record): FMPEquityQuoteQueryParams { + return FMPEquityQuoteQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPEquityQuoteQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const baseUrl = 'https://financialmodelingprep.com/stable/quote?' + const symbols = query.symbol.split(',') + const results: Record[] = [] + + const getOne = async (symbol: string) => { + const url = `${baseUrl}symbol=${symbol}&apikey=${apiKey}` + try { + const result = await amakeRequest[]>(url, { responseCallback }) + if (result && result.length > 0) { + results.push(...result) + } else { + console.warn(`Symbol Error: No data found for ${symbol}`) + } + } catch { + console.warn(`Symbol Error: No data found for ${symbol}`) + } + } + + await Promise.all(symbols.map(getOne)) + + if (results.length === 0) { + throw new EmptyDataError('No data found for the given symbols.') + } + + return results.sort((a, b) => { + const ai = symbols.indexOf(String(a.symbol ?? '')) + const bi = symbols.indexOf(String(b.symbol ?? '')) + return ai - bi + }) + } + + static override transformData( + query: FMPEquityQuoteQueryParams, + data: Record[], + ): FMPEquityQuoteData[] { + return data.map((d) => { + const aliased = applyAliases(d, ALIAS_DICT) + // Normalize timestamp to ISO date string + if (aliased.last_timestamp && typeof aliased.last_timestamp === 'number') { + aliased.last_timestamp = new Date(aliased.last_timestamp * 1000).toISOString().split('T')[0] + } + // Normalize percent + if (typeof aliased.change_percent === 'number') { + aliased.change_percent = aliased.change_percent / 100 + } + return FMPEquityQuoteDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/equity-screener.ts b/packages/opentypebb/src/providers/fmp/models/equity-screener.ts new file mode 100644 index 00000000..a59cd1fb --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/equity-screener.ts @@ -0,0 +1,134 @@ +/** + * FMP Equity Screener Model. + * Maps to: openbb_fmp/models/equity_screener.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityScreenerQueryParamsSchema, EquityScreenerDataSchema } from '../../../standard-models/equity-screener.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const DATA_ALIAS_DICT: Record = { + name: 'companyName', + market_cap: 'marketCap', + last_annual_dividend: 'lastAnnualDividend', + exchange: 'exchangeShortName', + exchange_name: 'exchange', + is_etf: 'isEtf', + actively_trading: 'isActivelyTrading', +} + +const QUERY_ALIAS_DICT: Record = { + mktcap_min: 'marketCapMoreThan', + mktcap_max: 'marketCapLowerThan', + price_min: 'priceMoreThan', + price_max: 'priceLowerThan', + beta_min: 'betaMoreThan', + beta_max: 'betaLowerThan', + volume_min: 'volumeMoreThan', + volume_max: 'volumeLowerThan', + dividend_min: 'dividendMoreThan', + dividend_max: 'dividendLowerThan', + is_active: 'isActivelyTrading', + is_etf: 'isEtf', + is_fund: 'isFund', + all_share_classes: 'includeAllShareClasses', +} + +const numOrNull = z.number().nullable().default(null) + +export const FMPEquityScreenerQueryParamsSchema = EquityScreenerQueryParamsSchema.extend({ + mktcap_min: z.coerce.number().nullable().default(null).describe('Minimum market capitalization.'), + mktcap_max: z.coerce.number().nullable().default(null).describe('Maximum market capitalization.'), + price_min: z.coerce.number().nullable().default(null).describe('Minimum price.'), + price_max: z.coerce.number().nullable().default(null).describe('Maximum price.'), + beta_min: z.coerce.number().nullable().default(null).describe('Minimum beta.'), + beta_max: z.coerce.number().nullable().default(null).describe('Maximum beta.'), + volume_min: z.coerce.number().nullable().default(null).describe('Minimum volume.'), + volume_max: z.coerce.number().nullable().default(null).describe('Maximum volume.'), + dividend_min: z.coerce.number().nullable().default(null).describe('Minimum dividend yield.'), + dividend_max: z.coerce.number().nullable().default(null).describe('Maximum dividend yield.'), + sector: z.string().nullable().default(null).describe('Sector filter.'), + industry: z.string().nullable().default(null).describe('Industry filter.'), + country: z.string().nullable().default(null).describe('Country filter.'), + exchange: z.string().nullable().default(null).describe('Exchange filter.'), + is_etf: z.boolean().nullable().default(null).describe('Filter for ETFs.'), + is_active: z.boolean().nullable().default(null).describe('Filter for actively trading.'), + is_fund: z.boolean().nullable().default(null).describe('Filter for funds.'), + all_share_classes: z.boolean().nullable().default(null).describe('Include all share classes.'), + limit: z.coerce.number().nullable().default(50000).describe('Maximum number of results.'), +}) +export type FMPEquityScreenerQueryParams = z.infer + +export const FMPEquityScreenerDataSchema = EquityScreenerDataSchema.extend({ + market_cap: numOrNull.describe('Market capitalization.'), + sector: z.string().nullable().default(null).describe('Sector.'), + industry: z.string().nullable().default(null).describe('Industry.'), + beta: numOrNull.describe('Beta.'), + price: numOrNull.describe('Current price.'), + last_annual_dividend: numOrNull.describe('Last annual dividend.'), + volume: numOrNull.describe('Volume.'), + exchange: z.string().nullable().default(null).describe('Exchange.'), + exchange_name: z.string().nullable().default(null).describe('Exchange name.'), + country: z.string().nullable().default(null).describe('Country.'), + is_etf: z.boolean().nullable().default(null).describe('Is ETF.'), + is_fund: z.boolean().nullable().default(null).describe('Is fund.'), + actively_trading: z.boolean().nullable().default(null).describe('Is actively trading.'), +}).passthrough() +export type FMPEquityScreenerData = z.infer + +export class FMPEquityScreenerFetcher extends Fetcher { + static override transformQuery(params: Record): FMPEquityScreenerQueryParams { + return FMPEquityScreenerQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPEquityScreenerQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const qs = new URLSearchParams() + qs.set('apikey', apiKey) + + // Map query params to FMP API parameter names + const mappings: [string, string, unknown][] = [ + ['marketCapMoreThan', 'mktcap_min', query.mktcap_min], + ['marketCapLowerThan', 'mktcap_max', query.mktcap_max], + ['priceMoreThan', 'price_min', query.price_min], + ['priceLowerThan', 'price_max', query.price_max], + ['betaMoreThan', 'beta_min', query.beta_min], + ['betaLowerThan', 'beta_max', query.beta_max], + ['volumeMoreThan', 'volume_min', query.volume_min], + ['volumeLowerThan', 'volume_max', query.volume_max], + ['dividendMoreThan', 'dividend_min', query.dividend_min], + ['dividendLowerThan', 'dividend_max', query.dividend_max], + ] + for (const [apiName, , val] of mappings) { + if (val != null) qs.set(apiName, String(val)) + } + if (query.sector) qs.set('sector', query.sector) + if (query.industry) qs.set('industry', query.industry) + if (query.country) qs.set('country', query.country) + if (query.exchange) qs.set('exchange', query.exchange) + if (query.is_etf != null) qs.set('isEtf', String(query.is_etf)) + if (query.is_active != null) qs.set('isActivelyTrading', String(query.is_active)) + if (query.is_fund != null) qs.set('isFund', String(query.is_fund)) + if (query.all_share_classes != null) qs.set('includeAllShareClasses', String(query.all_share_classes)) + if (query.limit) qs.set('limit', String(query.limit)) + + return getDataMany( + `https://financialmodelingprep.com/stable/company-screener?${qs.toString()}`, + ) + } + + static override transformData( + _query: FMPEquityScreenerQueryParams, + data: Record[], + ): FMPEquityScreenerData[] { + return data.map(d => { + const aliased = applyAliases(d, DATA_ALIAS_DICT) + return FMPEquityScreenerDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/esg-score.ts b/packages/opentypebb/src/providers/fmp/models/esg-score.ts new file mode 100644 index 00000000..29ec44bd --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/esg-score.ts @@ -0,0 +1,52 @@ +/** + * FMP ESG Score Model. + * Maps to: openbb_fmp/models/esg.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EsgScoreQueryParamsSchema, EsgScoreDataSchema } from '../../../standard-models/esg-score.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + company_name: 'companyName', + form_type: 'formType', + accepted_date: 'acceptedDate', + environmental_score: 'environmentalScore', + social_score: 'socialScore', + governance_score: 'governanceScore', + esg_score: 'ESGScore', +} + +export const FMPEsgScoreQueryParamsSchema = EsgScoreQueryParamsSchema +export type FMPEsgScoreQueryParams = z.infer + +export const FMPEsgScoreDataSchema = EsgScoreDataSchema +export type FMPEsgScoreData = z.infer + +export class FMPEsgScoreFetcher extends Fetcher { + static override transformQuery(params: Record): FMPEsgScoreQueryParams { + return FMPEsgScoreQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPEsgScoreQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + return getDataMany( + `https://financialmodelingprep.com/stable/esg-disclosures?symbol=${query.symbol}&apikey=${apiKey}`, + ) + } + + static override transformData( + _query: FMPEsgScoreQueryParams, + data: Record[], + ): FMPEsgScoreData[] { + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPEsgScoreDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/etf-countries.ts b/packages/opentypebb/src/providers/fmp/models/etf-countries.ts new file mode 100644 index 00000000..2aa12d23 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/etf-countries.ts @@ -0,0 +1,63 @@ +/** + * FMP ETF Countries Model. + * Maps to: openbb_fmp/models/etf_countries.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EtfCountriesQueryParamsSchema, EtfCountriesDataSchema } from '../../../standard-models/etf-countries.js' +import { getDataMany } from '../utils/helpers.js' + +export const FMPEtfCountriesQueryParamsSchema = EtfCountriesQueryParamsSchema +export type FMPEtfCountriesQueryParams = z.infer + +export const FMPEtfCountriesDataSchema = EtfCountriesDataSchema.passthrough() +export type FMPEtfCountriesData = z.infer + +export class FMPEtfCountriesFetcher extends Fetcher { + static override transformQuery(params: Record): FMPEtfCountriesQueryParams { + return FMPEtfCountriesQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPEtfCountriesQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const symbol = query.symbol + return getDataMany( + `https://financialmodelingprep.com/stable/etf/country-weightings?symbol=${symbol}&apikey=${apiKey}`, + ) + } + + static override transformData( + _query: FMPEtfCountriesQueryParams, + data: Record[], + ): FMPEtfCountriesData[] { + // FMP returns weightPercentage as a string like "50.25%" + // Python source parses this and normalizes (multiply by 0.01) + const results: FMPEtfCountriesData[] = [] + for (const d of data) { + const raw = d.weightPercentage + let weight = 0 + if (typeof raw === 'string') { + weight = parseFloat(raw.replace('%', '')) + if (isNaN(weight)) weight = 0 + } else if (typeof raw === 'number') { + weight = raw + } + // Filter out zero weights + if (weight === 0) continue + // Normalize: percentage points → decimal-like normalized (match Python: * 0.01) + weight = weight / 100 + results.push( + EtfCountriesDataSchema.parse({ + ...d, + weight, + country: d.country ?? '', + }), + ) + } + return results + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/etf-equity-exposure.ts b/packages/opentypebb/src/providers/fmp/models/etf-equity-exposure.ts new file mode 100644 index 00000000..0b49bfa8 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/etf-equity-exposure.ts @@ -0,0 +1,59 @@ +/** + * FMP ETF Equity Exposure Model. + * Maps to: openbb_fmp/models/etf_equity_exposure.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EtfEquityExposureQueryParamsSchema, EtfEquityExposureDataSchema } from '../../../standard-models/etf-equity-exposure.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + equity_symbol: 'assetExposure', + etf_symbol: 'etfSymbol', + shares: 'sharesNumber', + weight: 'weightPercentage', + market_value: 'marketValue', +} + +const numOrNull = z.number().nullable().default(null) + +export const FMPEtfEquityExposureQueryParamsSchema = EtfEquityExposureQueryParamsSchema +export type FMPEtfEquityExposureQueryParams = z.infer + +export const FMPEtfEquityExposureDataSchema = EtfEquityExposureDataSchema.passthrough() +export type FMPEtfEquityExposureData = z.infer + +export class FMPEtfEquityExposureFetcher extends Fetcher { + static override transformQuery(params: Record): FMPEtfEquityExposureQueryParams { + return FMPEtfEquityExposureQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPEtfEquityExposureQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const symbol = query.symbol + return getDataMany( + `https://financialmodelingprep.com/stable/etf/asset-exposure?symbol=${symbol}&apikey=${apiKey}`, + ) + } + + static override transformData( + _query: FMPEtfEquityExposureQueryParams, + data: Record[], + ): FMPEtfEquityExposureData[] { + const results = data.map((d) => { + const aliased = applyAliases(d, ALIAS_DICT) + // Normalize weight from percent to decimal + if (typeof aliased.weight === 'number') { + aliased.weight = aliased.weight / 100 + } + return FMPEtfEquityExposureDataSchema.parse(aliased) + }) + // Sort by market_value descending (matching Python) + return results.sort((a, b) => (Number(b.market_value ?? 0)) - (Number(a.market_value ?? 0))) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/etf-holdings.ts b/packages/opentypebb/src/providers/fmp/models/etf-holdings.ts new file mode 100644 index 00000000..94776e51 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/etf-holdings.ts @@ -0,0 +1,72 @@ +/** + * FMP ETF Holdings Model. + * Maps to: openbb_fmp/models/etf_holdings.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EtfHoldingsQueryParamsSchema, EtfHoldingsDataSchema } from '../../../standard-models/etf-holdings.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + shares: 'sharesNumber', + value: 'marketValue', + weight: 'weightPercentage', + symbol: 'asset', + updated: 'updatedAt', + cusip: 'securityCusip', +} + +const numOrNull = z.number().nullable().default(null) + +export const FMPEtfHoldingsQueryParamsSchema = EtfHoldingsQueryParamsSchema.extend({ + date: z.string().nullable().default(null).describe('A specific date to get data for. Entering a date will attempt to return the NPORT filing for the entered date. This needs to be exact date of the filing. Defaults to the latest filing.'), + cik: z.string().nullable().default(null).describe('The CIK number of the filing entity.'), +}) +export type FMPEtfHoldingsQueryParams = z.infer + +export const FMPEtfHoldingsDataSchema = EtfHoldingsDataSchema.extend({ + shares: numOrNull.describe('The number of shares held.'), + value: numOrNull.describe('The market value of the holding.'), + weight: numOrNull.describe('The weight of the holding in the ETF as a normalized percentage.'), + updated: z.string().nullable().default(null).describe('The last updated date.'), + cusip: z.string().nullable().default(null).describe('The CUSIP of the holding.'), + isin: z.string().nullable().default(null).describe('The ISIN of the holding.'), + country: z.string().nullable().default(null).describe('The country of the holding.'), + exchange: z.string().nullable().default(null).describe('The exchange of the holding.'), + asset_type: z.string().nullable().default(null).describe('The asset type of the holding.'), +}).passthrough() +export type FMPEtfHoldingsData = z.infer + +export class FMPEtfHoldingsFetcher extends Fetcher { + static override transformQuery(params: Record): FMPEtfHoldingsQueryParams { + return FMPEtfHoldingsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPEtfHoldingsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const symbol = query.symbol + let url = `https://financialmodelingprep.com/stable/etf/holdings?symbol=${symbol}&apikey=${apiKey}` + if (query.date) url += `&date=${query.date}` + if (query.cik) url += `&cik=${query.cik}` + return getDataMany(url) + } + + static override transformData( + _query: FMPEtfHoldingsQueryParams, + data: Record[], + ): FMPEtfHoldingsData[] { + return data.map((d) => { + const aliased = applyAliases(d, ALIAS_DICT) + // Normalize weight from percent to decimal (5.0 → 0.05) + if (typeof aliased.weight === 'number') { + aliased.weight = aliased.weight / 100 + } + return FMPEtfHoldingsDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/etf-info.ts b/packages/opentypebb/src/providers/fmp/models/etf-info.ts new file mode 100644 index 00000000..315352e6 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/etf-info.ts @@ -0,0 +1,72 @@ +/** + * FMP ETF Info Model. + * Maps to: openbb_fmp/models/etf_info.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EtfInfoQueryParamsSchema, EtfInfoDataSchema } from '../../../standard-models/etf-info.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + issuer: 'etfCompany', + cusip: 'securityCusip', + isin: 'securityIsin', + aum: 'assetsUnderManagement', + nav: 'netAssetValue', + currency: 'navCurrency', + volume_avg: 'avgVolume', + updated: 'updatedAt', +} + +const numOrNull = z.number().nullable().default(null) + +export const FMPEtfInfoQueryParamsSchema = EtfInfoQueryParamsSchema +export type FMPEtfInfoQueryParams = z.infer + +export const FMPEtfInfoDataSchema = EtfInfoDataSchema.extend({ + cusip: z.string().nullable().default(null).describe('The CUSIP of the ETF.'), + isin: z.string().nullable().default(null).describe('The ISIN of the ETF.'), + aum: numOrNull.describe('The assets under management of the ETF.'), + nav: numOrNull.describe('The net asset value of the ETF.'), + currency: z.string().nullable().default(null).describe('The currency of the ETF.'), + expense_ratio: numOrNull.describe('The expense ratio of the ETF as a normalized percentage.'), + holdings_count: numOrNull.describe('The number of holdings in the ETF.'), + volume_avg: numOrNull.describe('The average volume of the ETF.'), + updated: z.string().nullable().default(null).describe('The last updated date of the ETF.'), + asset_class: z.string().nullable().default(null).describe('The asset class of the ETF.'), + sector_list: z.string().nullable().default(null).describe('Sector list of the ETF.'), +}).passthrough() +export type FMPEtfInfoData = z.infer + +export class FMPEtfInfoFetcher extends Fetcher { + static override transformQuery(params: Record): FMPEtfInfoQueryParams { + return FMPEtfInfoQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPEtfInfoQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const symbol = query.symbol + return getDataMany( + `https://financialmodelingprep.com/stable/etf/info?symbol=${symbol}&apikey=${apiKey}`, + ) + } + + static override transformData( + _query: FMPEtfInfoQueryParams, + data: Record[], + ): FMPEtfInfoData[] { + return data.map((d) => { + const aliased = applyAliases(d, ALIAS_DICT) + // Normalize expense_ratio from percent to decimal (5.0 → 0.05) + if (typeof aliased.expense_ratio === 'number') { + aliased.expense_ratio = aliased.expense_ratio / 100 + } + return FMPEtfInfoDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/etf-search.ts b/packages/opentypebb/src/providers/fmp/models/etf-search.ts new file mode 100644 index 00000000..92f197e0 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/etf-search.ts @@ -0,0 +1,70 @@ +/** + * FMP ETF Search Model. + * Maps to: openbb_fmp/models/etf_search.py + * + * Uses the company-screener endpoint filtered to ETFs only, + * matching the Python implementation. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EtfSearchQueryParamsSchema, EtfSearchDataSchema } from '../../../standard-models/etf-search.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + name: 'companyName', + market_cap: 'marketCap', + last_annual_dividend: 'lastAnnualDividend', + exchange: 'exchangeShortName', + exchange_name: 'exchange', +} + +const numOrNull = z.number().nullable().default(null) + +export const FMPEtfSearchQueryParamsSchema = EtfSearchQueryParamsSchema.extend({ + exchange: z.string().nullable().default(null).describe('The exchange code the ETF is listed on.'), + is_active: z.boolean().default(true).describe('Whether the ETF is actively trading.'), +}) +export type FMPEtfSearchQueryParams = z.infer + +export const FMPEtfSearchDataSchema = EtfSearchDataSchema.extend({ + market_cap: numOrNull.describe('The market cap of the ETF.'), + sector: z.string().nullable().default(null).describe('The sector of the ETF.'), + industry: z.string().nullable().default(null).describe('The industry of the ETF.'), + beta: numOrNull.describe('The beta of the ETF.'), + price: numOrNull.describe('The current price of the ETF.'), + last_annual_dividend: numOrNull.describe('The last annual dividend of the ETF.'), + volume: numOrNull.describe('The current volume of the ETF.'), + exchange: z.string().nullable().default(null).describe('The exchange the ETF is listed on.'), + exchange_name: z.string().nullable().default(null).describe('The name of the exchange.'), + country: z.string().nullable().default(null).describe('The country of the ETF.'), +}).passthrough() +export type FMPEtfSearchData = z.infer + +export class FMPEtfSearchFetcher extends Fetcher { + static override transformQuery(params: Record): FMPEtfSearchQueryParams { + return FMPEtfSearchQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPEtfSearchQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + let url = `https://financialmodelingprep.com/stable/company-screener?isEtf=true&isFund=false&isActivelyTrading=${query.is_active}&apikey=${apiKey}` + if (query.query) url += `&query=${encodeURIComponent(query.query)}` + if (query.exchange) url += `&exchange=${encodeURIComponent(query.exchange)}` + return getDataMany(url) + } + + static override transformData( + _query: FMPEtfSearchQueryParams, + data: Record[], + ): FMPEtfSearchData[] { + return data.map((d) => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPEtfSearchDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/etf-sectors.ts b/packages/opentypebb/src/providers/fmp/models/etf-sectors.ts new file mode 100644 index 00000000..91a24784 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/etf-sectors.ts @@ -0,0 +1,47 @@ +/** + * FMP ETF Sectors Model. + * Maps to: openbb_fmp/models/etf_sectors.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EtfSectorsQueryParamsSchema, EtfSectorsDataSchema } from '../../../standard-models/etf-sectors.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { weight: 'weightPercentage' } + +export const FMPEtfSectorsQueryParamsSchema = EtfSectorsQueryParamsSchema +export type FMPEtfSectorsQueryParams = z.infer + +export const FMPEtfSectorsDataSchema = EtfSectorsDataSchema.passthrough() +export type FMPEtfSectorsData = z.infer + +export class FMPEtfSectorsFetcher extends Fetcher { + static override transformQuery(params: Record): FMPEtfSectorsQueryParams { + return FMPEtfSectorsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPEtfSectorsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const symbol = query.symbol + return getDataMany( + `https://financialmodelingprep.com/stable/etf/sector-weightings?symbol=${symbol}&apikey=${apiKey}`, + ) + } + + static override transformData( + _query: FMPEtfSectorsQueryParams, + data: Record[], + ): FMPEtfSectorsData[] { + const results = data.map((d) => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPEtfSectorsDataSchema.parse(aliased) + }) + // Sort by weight descending + return results.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0)) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/executive-compensation.ts b/packages/opentypebb/src/providers/fmp/models/executive-compensation.ts new file mode 100644 index 00000000..90ebdf82 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/executive-compensation.ts @@ -0,0 +1,103 @@ +/** + * FMP Executive Compensation Model. + * Maps to: openbb_fmp/models/executive_compensation.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { ExecutiveCompensationQueryParamsSchema, ExecutiveCompensationDataSchema } from '../../../standard-models/executive-compensation.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + company_name: 'companyName', + industry: 'industryTitle', + url: 'link', + executive: 'nameAndPosition', + report_date: 'filingDate', +} + +export const FMPExecutiveCompensationQueryParamsSchema = ExecutiveCompensationQueryParamsSchema.extend({ + year: z.coerce.number().default(-1).describe('Filters results by year, enter 0 for all data available. Default is the most recent year in the dataset, -1.'), +}) +export type FMPExecutiveCompensationQueryParams = z.infer + +export const FMPExecutiveCompensationDataSchema = ExecutiveCompensationDataSchema.extend({ + accepted_date: z.string().nullable().default(null).describe('Date the filing was accepted.'), + url: z.string().nullable().default(null).describe('URL to the filing data.'), +}).passthrough() +export type FMPExecutiveCompensationData = z.infer + +export class FMPExecutiveCompensationFetcher extends Fetcher { + static override transformQuery(params: Record): FMPExecutiveCompensationQueryParams { + return FMPExecutiveCompensationQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPExecutiveCompensationQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const symbols = query.symbol.split(',').map(s => s.trim()).filter(Boolean) + const results: Record[] = [] + + const settled = await Promise.allSettled( + symbols.map(symbol => + getDataMany( + `https://financialmodelingprep.com/stable/governance-executive-compensation?symbol=${symbol}&apikey=${apiKey}`, + ), + ), + ) + + for (const r of settled) { + if (r.status === 'fulfilled' && r.value?.length) { + results.push(...r.value) + } + } + + if (!results.length) { + throw new EmptyDataError('No executive compensation data found.') + } + + return results + } + + static override transformData( + query: FMPExecutiveCompensationQueryParams, + data: Record[], + ): FMPExecutiveCompensationData[] { + const symbols = query.symbol.split(',').map(s => s.trim()).filter(Boolean) + const filtered: FMPExecutiveCompensationData[] = [] + + for (const symbol of symbols) { + const symbolData = data.filter(d => String(d.symbol).toUpperCase() === symbol.toUpperCase()) + + if (symbolData.length && query.year !== 0) { + // Get max year or filter by specific year + const targetYear = query.year === -1 + ? Math.max(...symbolData.map(d => Number(d.year ?? 0))) + : query.year + + const yearData = symbolData.filter(d => Number(d.year ?? 0) === targetYear) + for (const d of yearData) { + const aliased = applyAliases(d, ALIAS_DICT) + filtered.push(FMPExecutiveCompensationDataSchema.parse(aliased)) + } + } else { + // Return all data sorted by year descending + const sorted = [...symbolData].sort((a, b) => Number(b.year ?? 0) - Number(a.year ?? 0)) + for (const d of sorted) { + const aliased = applyAliases(d, ALIAS_DICT) + filtered.push(FMPExecutiveCompensationDataSchema.parse(aliased)) + } + } + } + + if (!filtered.length) { + throw new EmptyDataError('No data found for given symbols and year.') + } + + return filtered + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/financial-ratios.ts b/packages/opentypebb/src/providers/fmp/models/financial-ratios.ts new file mode 100644 index 00000000..b4b39089 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/financial-ratios.ts @@ -0,0 +1,159 @@ +/** + * FMP Financial Ratios Model. + * Maps to: openbb_fmp/models/financial_ratios.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { FinancialRatiosQueryParamsSchema, FinancialRatiosDataSchema } from '../../../standard-models/financial-ratios.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getDataMany } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPFinancialRatiosQueryParamsSchema = FinancialRatiosQueryParamsSchema.extend({ + ttm: z.enum(['include', 'exclude', 'only']).default('only').describe("Specify whether to include, exclude, or only show TTM data. Default: 'only'."), + period: z.enum(['q1', 'q2', 'q3', 'q4', 'fy', 'annual', 'quarter']).default('annual').describe('Specify the fiscal period for the data.'), + limit: z.number().int().nullable().default(null).describe('Number of most recent reporting periods to return.'), +}) + +export type FMPFinancialRatiosQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + currency: 'reportedCurrency', + period_ending: 'date', + fiscal_period: 'period', + price_to_earnings: 'priceToEarningsRatio', + price_to_book: 'priceToBookRatio', + price_to_sales: 'priceToSalesRatio', + debt_to_equity: 'debtToEquityRatio', + debt_to_assets: 'debtToAssetsRatio', + current_ratio: 'currentRatio', + gross_profit_margin: 'grossProfitMargin', + net_profit_margin: 'netProfitMargin', + operating_profit_margin: 'operatingProfitMargin', + dividend_yield: 'dividendYield', + return_on_equity: 'returnOnEquity', + return_on_assets: 'returnOnAssets', +} + +// TTM alias variants +const TTM_ALIAS_DICT: Record = { + currency: 'reportedCurrency', + period_ending: 'date', + fiscal_period: 'fiscal_period', + price_to_earnings: 'priceToEarningsRatioTTM', + price_to_book: 'priceToBookRatioTTM', + price_to_sales: 'priceToSalesRatioTTM', + debt_to_equity: 'debtToEquityRatioTTM', + debt_to_assets: 'debtToAssetsRatioTTM', + current_ratio: 'currentRatioTTM', + gross_profit_margin: 'grossProfitMarginTTM', + net_profit_margin: 'netProfitMarginTTM', + operating_profit_margin: 'operatingProfitMarginTTM', + dividend_yield: 'dividendYieldTTM', + return_on_equity: 'returnOnEquityTTM', + return_on_assets: 'returnOnAssetsTTM', +} + +export const FMPFinancialRatiosDataSchema = FinancialRatiosDataSchema.extend({ + currency: z.string().nullable().default(null).describe('Currency in which the company reports financials.'), + gross_profit_margin: z.number().nullable().default(null).describe('Gross profit margin.'), + net_profit_margin: z.number().nullable().default(null).describe('Net profit margin.'), + operating_profit_margin: z.number().nullable().default(null).describe('Operating profit margin.'), + current_ratio: z.number().nullable().default(null).describe('Current ratio.'), + debt_to_equity: z.number().nullable().default(null).describe('Debt to equity ratio.'), + debt_to_assets: z.number().nullable().default(null).describe('Debt to assets ratio.'), + price_to_earnings: z.number().nullable().default(null).describe('Price to earnings ratio.'), + price_to_book: z.number().nullable().default(null).describe('Price to book ratio.'), + price_to_sales: z.number().nullable().default(null).describe('Price to sales ratio.'), + dividend_yield: z.number().nullable().default(null).describe('Dividend yield.'), + return_on_equity: z.number().nullable().default(null).describe('Return on equity.'), + return_on_assets: z.number().nullable().default(null).describe('Return on assets.'), +}).passthrough() + +export type FMPFinancialRatiosData = z.infer + +// --- Fetcher --- + +export class FMPFinancialRatiosFetcher extends Fetcher { + static override transformQuery(params: Record): FMPFinancialRatiosQueryParams { + return FMPFinancialRatiosQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPFinancialRatiosQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const symbols = query.symbol.split(',') + const results: Record[] = [] + const baseUrl = 'https://financialmodelingprep.com/stable/ratios' + + const getOne = async (symbol: string) => { + try { + const ttmUrl = `${baseUrl}-ttm?symbol=${symbol}&apikey=${apiKey}` + const limit = query.ttm !== 'only' ? (query.limit ?? 5) : 1 + const metricsUrl = `${baseUrl}?symbol=${symbol}&period=${query.period}&limit=${limit}&apikey=${apiKey}` + + const [ttmData, metricsData] = await Promise.all([ + getDataMany(ttmUrl).catch(() => []), + getDataMany(metricsUrl).catch(() => []), + ]) + + const result: Record[] = [] + let currency: string | null = null + + if (metricsData.length > 0) { + if (query.ttm !== 'only') { + result.push(...metricsData) + } + currency = metricsData[0].reportedCurrency as string ?? null + } + + if (ttmData.length > 0 && query.ttm !== 'exclude') { + const ttmResult = { ...ttmData[0] } + ttmResult.date = new Date().toISOString().split('T')[0] + ttmResult.fiscal_period = 'TTM' + ttmResult.fiscal_year = new Date().getFullYear() + if (currency) ttmResult.reportedCurrency = currency + result.unshift(ttmResult) + } + + if (result.length > 0) { + results.push(...result) + } else { + console.warn(`Symbol Error: No data found for ${symbol}.`) + } + } catch { + console.warn(`Symbol Error: No data found for ${symbol}.`) + } + } + + await Promise.all(symbols.map(getOne)) + + if (results.length === 0) { + throw new EmptyDataError('No data found for given symbols.') + } + + return results + } + + static override transformData( + query: FMPFinancialRatiosQueryParams, + data: Record[], + ): FMPFinancialRatiosData[] { + const sorted = [...data].sort((a, b) => + String(b.date ?? '').localeCompare(String(a.date ?? '')), + ) + + return sorted.map((d) => { + const isTTM = d.fiscal_period === 'TTM' + const aliased = applyAliases(d, isTTM ? TTM_ALIAS_DICT : ALIAS_DICT) + return FMPFinancialRatiosDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/forward-ebitda-estimates.ts b/packages/opentypebb/src/providers/fmp/models/forward-ebitda-estimates.ts new file mode 100644 index 00000000..a73157da --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/forward-ebitda-estimates.ts @@ -0,0 +1,71 @@ +/** + * FMP Forward EBITDA Estimates Model. + * Maps to: openbb_fmp/models/forward_ebitda_estimates.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { ForwardEbitdaEstimatesQueryParamsSchema, ForwardEbitdaEstimatesDataSchema } from '../../../standard-models/forward-ebitda-estimates.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPForwardEbitdaEstimatesQueryParamsSchema = ForwardEbitdaEstimatesQueryParamsSchema.extend({ + fiscal_period: z.enum(['annual', 'quarter']).nullable().default(null).describe('The fiscal period of the estimate.'), + limit: z.coerce.number().int().nullable().default(null).describe('The number of data entries to return.'), + include_historical: z.boolean().default(false).describe('If true, include historical data.'), +}) + +export type FMPForwardEbitdaEstimatesQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + period_ending: 'date', + high_estimate: 'ebitdaHigh', + low_estimate: 'ebitdaLow', + mean: 'ebitdaAvg', +} + +export const FMPForwardEbitdaEstimatesDataSchema = ForwardEbitdaEstimatesDataSchema.extend({}).passthrough() +export type FMPForwardEbitdaEstimatesData = z.infer + +// --- Fetcher --- + +export class FMPForwardEbitdaEstimatesFetcher extends Fetcher { + static override transformQuery(params: Record): FMPForwardEbitdaEstimatesQueryParams { + return FMPForwardEbitdaEstimatesQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPForwardEbitdaEstimatesQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const url = 'https://financialmodelingprep.com/stable/analyst-estimates' + + `?symbol=${query.symbol}` + + (query.fiscal_period ? `&period=${query.fiscal_period}` : '') + + (query.limit ? `&limit=${query.limit}` : '') + + `&apikey=${apiKey}` + return getDataMany(url) + } + + static override transformData( + query: FMPForwardEbitdaEstimatesQueryParams, + data: Record[], + ): FMPForwardEbitdaEstimatesData[] { + let filtered = data + if (!query.include_historical) { + const currentYear = new Date().getFullYear() + filtered = data.filter(d => { + const fy = Number(d.calendarYear ?? d.fiscal_year ?? 0) + return fy >= currentYear + }) + } + return filtered.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPForwardEbitdaEstimatesDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/forward-eps-estimates.ts b/packages/opentypebb/src/providers/fmp/models/forward-eps-estimates.ts new file mode 100644 index 00000000..b3577fe4 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/forward-eps-estimates.ts @@ -0,0 +1,72 @@ +/** + * FMP Forward EPS Estimates Model. + * Maps to: openbb_fmp/models/forward_eps_estimates.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { ForwardEpsEstimatesQueryParamsSchema, ForwardEpsEstimatesDataSchema } from '../../../standard-models/forward-eps-estimates.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPForwardEpsEstimatesQueryParamsSchema = ForwardEpsEstimatesQueryParamsSchema.extend({ + fiscal_period: z.enum(['annual', 'quarter']).nullable().default(null).describe('The fiscal period of the estimate.'), + limit: z.coerce.number().int().nullable().default(null).describe('The number of data entries to return.'), + include_historical: z.boolean().default(false).describe('If true, include historical data.'), +}) + +export type FMPForwardEpsEstimatesQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + number_of_analysts: 'numberAnalystsEps', + high_estimate: 'epsHigh', + low_estimate: 'epsLow', + mean: 'epsAvg', +} + +export const FMPForwardEpsEstimatesDataSchema = ForwardEpsEstimatesDataSchema.extend({}).passthrough() +export type FMPForwardEpsEstimatesData = z.infer + +// --- Fetcher --- + +export class FMPForwardEpsEstimatesFetcher extends Fetcher { + static override transformQuery(params: Record): FMPForwardEpsEstimatesQueryParams { + return FMPForwardEpsEstimatesQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPForwardEpsEstimatesQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const url = 'https://financialmodelingprep.com/stable/analyst-estimates' + + `?symbol=${query.symbol}` + + (query.fiscal_period ? `&period=${query.fiscal_period}` : '') + + (query.limit ? `&limit=${query.limit}` : '') + + `&apikey=${apiKey}` + return getDataMany(url) + } + + static override transformData( + query: FMPForwardEpsEstimatesQueryParams, + data: Record[], + ): FMPForwardEpsEstimatesData[] { + // Filter to current/future fiscal years unless include_historical + let filtered = data + if (!query.include_historical) { + const currentYear = new Date().getFullYear() + filtered = data.filter(d => { + const fy = Number(d.calendarYear ?? d.fiscal_year ?? 0) + return fy >= currentYear + }) + } + return filtered.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPForwardEpsEstimatesDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/gainers.ts b/packages/opentypebb/src/providers/fmp/models/gainers.ts new file mode 100644 index 00000000..51d9ab8e --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/gainers.ts @@ -0,0 +1,49 @@ +/** + * FMP Top Gainers Model. + * Maps to: openbb_fmp/models/equity_gainers.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityPerformanceQueryParamsSchema, EquityPerformanceDataSchema } from '../../../standard-models/equity-performance.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { percent_change: 'changesPercentage' } + +export const FMPGainersQueryParamsSchema = EquityPerformanceQueryParamsSchema +export type FMPGainersQueryParams = z.infer + +export const FMPGainersDataSchema = EquityPerformanceDataSchema.extend({ + exchange: z.string().describe('Stock exchange where the security is listed.'), +}).passthrough() +export type FMPGainersData = z.infer + +export class FMPGainersFetcher extends Fetcher { + static override transformQuery(params: Record): FMPGainersQueryParams { + return FMPGainersQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPGainersQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + return getDataMany(`https://financialmodelingprep.com/stable/biggest-gainers?apikey=${apiKey}`) + } + + static override transformData( + query: FMPGainersQueryParams, + data: Record[], + ): FMPGainersData[] { + const sorted = [...data].sort((a, b) => { + const diff = Number(b.changesPercentage ?? 0) - Number(a.changesPercentage ?? 0) + return query.sort === 'desc' ? diff : -diff + }) + return sorted.map((d) => { + const aliased = applyAliases(d, ALIAS_DICT) + if (typeof aliased.percent_change === 'number') aliased.percent_change = aliased.percent_change / 100 + return FMPGainersDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/government-trades.ts b/packages/opentypebb/src/providers/fmp/models/government-trades.ts new file mode 100644 index 00000000..106ec20e --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/government-trades.ts @@ -0,0 +1,176 @@ +/** + * FMP Government Trades Model. + * Maps to: openbb_fmp/models/government_trades.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { GovernmentTradesQueryParamsSchema, GovernmentTradesDataSchema } from '../../../standard-models/government-trades.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' + +const ALIAS_DICT: Record = { + symbol: 'ticker', + transaction_date: 'transactionDate', + representative: 'office', + url: 'link', + transaction_type: 'type', + date: 'disclosureDate', +} + +const KEYS_TO_REMOVE = new Set([ + 'district', + 'capitalGainsOver200USD', + 'disclosureYear', + 'firstName', + 'lastName', +]) + +const KEYS_TO_RENAME: Record = { + dateRecieved: 'date', + disclosureDate: 'date', +} + +export const FMPGovernmentTradesQueryParamsSchema = GovernmentTradesQueryParamsSchema +export type FMPGovernmentTradesQueryParams = z.infer + +export const FMPGovernmentTradesDataSchema = GovernmentTradesDataSchema.extend({ + chamber: z.enum(['House', 'Senate']).describe('Government Chamber - House or Senate.'), + owner: z.string().nullable().default(null).describe('Ownership status (e.g., Spouse, Joint).'), + asset_type: z.string().nullable().default(null).describe('Type of asset involved in the transaction.'), + asset_description: z.string().nullable().default(null).describe('Description of the asset.'), + transaction_type: z.string().nullable().default(null).describe('Type of transaction (e.g., Sale, Purchase).'), + amount: z.string().nullable().default(null).describe('Transaction amount range.'), + comment: z.string().nullable().default(null).describe('Additional comments on the transaction.'), + url: z.string().nullable().default(null).describe('Link to the transaction document.'), +}).strip() +export type FMPGovernmentTradesData = z.infer + +/** Determine asset_type from description if missing */ +function inferAssetType(d: Record): string | null { + const desc = String(d.assetDescription ?? d.asset_description ?? '').toLowerCase() + const hasTicker = !!(d.ticker || d.symbol) + + if (hasTicker) { + return desc.includes('etf') ? 'ETF' : 'Stock' + } + if (desc.includes('treasury') || desc.includes('bill')) return 'Treasury' + if (desc.includes('%') || desc.includes('due') || desc.includes('pct')) return 'Bond' + if (desc.includes('fund')) return 'Fund' + if (desc.includes('etf')) return 'ETF' + return null +} + +export class FMPGovernmentTradesFetcher extends Fetcher { + static override transformQuery(params: Record): FMPGovernmentTradesQueryParams { + return FMPGovernmentTradesQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPGovernmentTradesQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const baseUrl = 'https://financialmodelingprep.com/stable/' + const chamberUrls: Record = { + house: ['house-trades'], + senate: ['senate-trades'], + all: ['house-trades', 'senate-trades'], + } + const endpoints = chamberUrls[query.chamber] ?? chamberUrls.all + const results: Record[] = [] + + if (query.symbol) { + // Symbol-based: fetch for each symbol × each chamber + const symbols = query.symbol.split(',').map(s => s.trim()).filter(Boolean) + const urls: { url: string; chamber: string }[] = [] + for (const symbol of symbols) { + for (const ep of endpoints) { + urls.push({ + url: `${baseUrl}${ep}?symbol=${symbol}&apikey=${apiKey}`, + chamber: ep.includes('senate') ? 'Senate' : 'House', + }) + } + } + + const settled = await Promise.allSettled( + urls.map(async ({ url, chamber }) => { + const data = await amakeRequest(url) as any[] + return (data ?? []).map((d: any) => ({ ...d, chamber })) + }), + ) + + for (const r of settled) { + if (r.status === 'fulfilled' && r.value?.length) { + results.push(...r.value) + } + } + } else { + // No symbol: fetch latest trades (up to limit) + const limit = query.limit ?? 1000 + for (const ep of endpoints) { + const chamber = ep.includes('senate') ? 'Senate' : 'House' + try { + const latestEp = ep.replace('trades', 'latest') + const data = await amakeRequest( + `${baseUrl}${latestEp}?page=0&limit=${Math.min(limit, 250)}&apikey=${apiKey}`, + ) as any[] + if (data?.length) { + results.push(...data.map((d: any) => ({ ...d, chamber }))) + } + } catch { + // Ignore errors for individual chambers + } + } + } + + if (!results.length) { + throw new EmptyDataError('No government trades data returned.') + } + + // Process: rename keys, remove unwanted keys, add chamber + return results.map(entry => { + const processed: Record = {} + for (const [k, v] of Object.entries(entry)) { + if (KEYS_TO_REMOVE.has(k)) continue + const newKey = KEYS_TO_RENAME[k] ?? k + processed[newKey] = v + } + return processed + }) + } + + static override transformData( + query: FMPGovernmentTradesQueryParams, + data: Record[], + ): FMPGovernmentTradesData[] { + const results = data + .filter(d => { + // Skip entries where all values are "--" or empty + const vals = Object.values(d) + return vals.some(v => v && v !== '--') + }) + .map(d => { + // Fill missing owner + if (!d.owner) d.owner = 'Self' + // Fill missing asset_type + if (!d.assetType && !d.asset_type) { + d.asset_type = inferAssetType(d) + } + // Clean "--" values to null + for (const [k, v] of Object.entries(d)) { + if (v === '--') d[k] = null + } + const aliased = applyAliases(d, ALIAS_DICT) + return FMPGovernmentTradesDataSchema.parse(aliased) + }) + + // Sort by date descending + results.sort((a, b) => (b.date > a.date ? 1 : b.date < a.date ? -1 : 0)) + + // Apply limit + const limit = query.limit ?? results.length + return results.slice(0, limit) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/historical-dividends.ts b/packages/opentypebb/src/providers/fmp/models/historical-dividends.ts new file mode 100644 index 00000000..4cffc78c --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/historical-dividends.ts @@ -0,0 +1,63 @@ +/** + * FMP Historical Dividends Model. + * Maps to: openbb_fmp/models/historical_dividends.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { HistoricalDividendsQueryParamsSchema, HistoricalDividendsDataSchema } from '../../../standard-models/historical-dividends.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + ex_dividend_date: 'date', + amount: 'dividend', + adjusted_amount: 'adjDividend', + dividend_yield: 'yield', + record_date: 'recordDate', + payment_date: 'paymentDate', + declaration_date: 'declarationDate', +} + +const numOrNull = z.number().nullable().default(null) + +export const FMPHistoricalDividendsQueryParamsSchema = HistoricalDividendsQueryParamsSchema.extend({ + limit: z.coerce.number().nullable().default(null).describe('The number of data entries to return.'), +}) +export type FMPHistoricalDividendsQueryParams = z.infer + +export const FMPHistoricalDividendsDataSchema = HistoricalDividendsDataSchema.extend({ + declaration_date: z.string().nullable().default(null).describe('Declaration date of the dividend.'), + record_date: z.string().nullable().default(null).describe('Record date of the dividend.'), + payment_date: z.string().nullable().default(null).describe('Payment date of the dividend.'), + adjusted_amount: numOrNull.describe('Adjusted dividend amount.'), + dividend_yield: numOrNull.describe('Dividend yield.'), + frequency: z.string().nullable().default(null).describe('Frequency of the dividend.'), +}).passthrough() +export type FMPHistoricalDividendsData = z.infer + +export class FMPHistoricalDividendsFetcher extends Fetcher { + static override transformQuery(params: Record): FMPHistoricalDividendsQueryParams { + return FMPHistoricalDividendsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPHistoricalDividendsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + let url = `https://financialmodelingprep.com/stable/dividends?symbol=${query.symbol}&apikey=${apiKey}` + if (query.limit) url += `&limit=${query.limit}` + return getDataMany(url) + } + + static override transformData( + _query: FMPHistoricalDividendsQueryParams, + data: Record[], + ): FMPHistoricalDividendsData[] { + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPHistoricalDividendsDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/historical-employees.ts b/packages/opentypebb/src/providers/fmp/models/historical-employees.ts new file mode 100644 index 00000000..953c4842 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/historical-employees.ts @@ -0,0 +1,56 @@ +/** + * FMP Historical Employees Model. + * Maps to: openbb_fmp/models/historical_employees.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { HistoricalEmployeesQueryParamsSchema, HistoricalEmployeesDataSchema } from '../../../standard-models/historical-employees.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + company_name: 'companyName', + employees: 'employeeCount', + date: 'periodOfReport', + source: 'formType', + url: 'source', +} + +export const FMPHistoricalEmployeesQueryParamsSchema = HistoricalEmployeesQueryParamsSchema.extend({ + limit: z.coerce.number().nullable().default(null).describe('The number of data entries to return.'), +}) +export type FMPHistoricalEmployeesQueryParams = z.infer + +export const FMPHistoricalEmployeesDataSchema = HistoricalEmployeesDataSchema.extend({ + company_name: z.string().nullable().default(null).describe('Name of the company.'), + source: z.string().nullable().default(null).describe('Source form type.'), + url: z.string().nullable().default(null).describe('URL to the source filing.'), +}).passthrough() +export type FMPHistoricalEmployeesData = z.infer + +export class FMPHistoricalEmployeesFetcher extends Fetcher { + static override transformQuery(params: Record): FMPHistoricalEmployeesQueryParams { + return FMPHistoricalEmployeesQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPHistoricalEmployeesQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + let url = `https://financialmodelingprep.com/stable/historical-employee-count?symbol=${query.symbol}&apikey=${apiKey}` + if (query.limit) url += `&limit=${query.limit}` + return getDataMany(url) + } + + static override transformData( + _query: FMPHistoricalEmployeesQueryParams, + data: Record[], + ): FMPHistoricalEmployeesData[] { + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPHistoricalEmployeesDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/historical-eps.ts b/packages/opentypebb/src/providers/fmp/models/historical-eps.ts new file mode 100644 index 00000000..ddd3f45f --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/historical-eps.ts @@ -0,0 +1,58 @@ +/** + * FMP Historical EPS Model. + * Maps to: openbb_fmp/models/historical_eps.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { HistoricalEpsQueryParamsSchema, HistoricalEpsDataSchema } from '../../../standard-models/historical-eps.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + eps_actual: 'epsActual', + eps_estimated: 'epsEstimated', + revenue_estimated: 'revenueEstimated', + revenue_actual: 'revenueActual', + updated: 'lastUpdated', +} + +const numOrNull = z.number().nullable().default(null) + +export const FMPHistoricalEpsQueryParamsSchema = HistoricalEpsQueryParamsSchema.extend({ + limit: z.coerce.number().nullable().default(null).describe('The number of data entries to return.'), +}) +export type FMPHistoricalEpsQueryParams = z.infer + +export const FMPHistoricalEpsDataSchema = HistoricalEpsDataSchema.extend({ + revenue_estimated: numOrNull.describe('Estimated revenue.'), + revenue_actual: numOrNull.describe('Actual revenue.'), + updated: z.string().nullable().default(null).describe('Last updated date.'), +}).passthrough() +export type FMPHistoricalEpsData = z.infer + +export class FMPHistoricalEpsFetcher extends Fetcher { + static override transformQuery(params: Record): FMPHistoricalEpsQueryParams { + return FMPHistoricalEpsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPHistoricalEpsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + let url = `https://financialmodelingprep.com/stable/earnings?symbol=${query.symbol}&apikey=${apiKey}` + if (query.limit) url += `&limit=${query.limit}` + return getDataMany(url) + } + + static override transformData( + _query: FMPHistoricalEpsQueryParams, + data: Record[], + ): FMPHistoricalEpsData[] { + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPHistoricalEpsDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/historical-market-cap.ts b/packages/opentypebb/src/providers/fmp/models/historical-market-cap.ts new file mode 100644 index 00000000..248d53a2 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/historical-market-cap.ts @@ -0,0 +1,54 @@ +/** + * FMP Historical Market Cap Model. + * Maps to: openbb_fmp/models/historical_market_cap.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { HistoricalMarketCapQueryParamsSchema, HistoricalMarketCapDataSchema } from '../../../standard-models/historical-market-cap.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + market_cap: 'marketCap', +} + +export const FMPHistoricalMarketCapQueryParamsSchema = HistoricalMarketCapQueryParamsSchema.extend({ + limit: z.coerce.number().nullable().default(500).describe('The number of data entries to return.'), +}) +export type FMPHistoricalMarketCapQueryParams = z.infer + +export const FMPHistoricalMarketCapDataSchema = HistoricalMarketCapDataSchema +export type FMPHistoricalMarketCapData = z.infer + +export class FMPHistoricalMarketCapFetcher extends Fetcher { + static override transformQuery(params: Record): FMPHistoricalMarketCapQueryParams { + return FMPHistoricalMarketCapQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPHistoricalMarketCapQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const qs = new URLSearchParams() + qs.set('symbol', query.symbol) + qs.set('apikey', apiKey) + if (query.limit) qs.set('limit', String(query.limit)) + if (query.start_date) qs.set('from', query.start_date) + if (query.end_date) qs.set('to', query.end_date) + return getDataMany( + `https://financialmodelingprep.com/stable/historical-market-capitalization?${qs.toString()}`, + ) + } + + static override transformData( + _query: FMPHistoricalMarketCapQueryParams, + data: Record[], + ): FMPHistoricalMarketCapData[] { + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPHistoricalMarketCapDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/historical-splits.ts b/packages/opentypebb/src/providers/fmp/models/historical-splits.ts new file mode 100644 index 00000000..755ee2dd --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/historical-splits.ts @@ -0,0 +1,38 @@ +/** + * FMP Historical Splits Model. + * Maps to: openbb_fmp/models/historical_splits.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { HistoricalSplitsQueryParamsSchema, HistoricalSplitsDataSchema } from '../../../standard-models/historical-splits.js' +import { getDataMany } from '../utils/helpers.js' + +export const FMPHistoricalSplitsQueryParamsSchema = HistoricalSplitsQueryParamsSchema +export type FMPHistoricalSplitsQueryParams = z.infer + +export const FMPHistoricalSplitsDataSchema = HistoricalSplitsDataSchema.passthrough() +export type FMPHistoricalSplitsData = z.infer + +export class FMPHistoricalSplitsFetcher extends Fetcher { + static override transformQuery(params: Record): FMPHistoricalSplitsQueryParams { + return FMPHistoricalSplitsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPHistoricalSplitsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + return getDataMany( + `https://financialmodelingprep.com/stable/splits?symbol=${query.symbol}&apikey=${apiKey}`, + ) + } + + static override transformData( + _query: FMPHistoricalSplitsQueryParams, + data: Record[], + ): FMPHistoricalSplitsData[] { + return data.map(d => FMPHistoricalSplitsDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/income-statement-growth.ts b/packages/opentypebb/src/providers/fmp/models/income-statement-growth.ts new file mode 100644 index 00000000..4d17b7db --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/income-statement-growth.ts @@ -0,0 +1,106 @@ +/** + * FMP Income Statement Growth Model. + * Maps to: openbb_fmp/models/income_statement_growth.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { IncomeStatementGrowthQueryParamsSchema, IncomeStatementGrowthDataSchema } from '../../../standard-models/income-statement-growth.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPIncomeStatementGrowthQueryParamsSchema = IncomeStatementGrowthQueryParamsSchema.extend({ + period: z.enum(['annual', 'quarter']).default('annual').describe('Time period of the data to return.'), +}) + +export type FMPIncomeStatementGrowthQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + period_ending: 'date', + fiscal_year: 'calendarYear', + fiscal_period: 'period', + growth_ebit: 'growthEBIT', + growth_ebitda: 'growthEBITDA', + growth_basic_earings_per_share: 'growthEPS', + growth_gross_profit_margin: 'growthGrossProfitRatio', + growth_consolidated_net_income: 'growthNetIncome', + growth_diluted_earnings_per_share: 'growthEPSDiluted', + growth_weighted_average_basic_shares_outstanding: 'growthWeightedAverageShsOut', + growth_weighted_average_diluted_shares_outstanding: 'growthWeightedAverageShsOutDil', + growth_research_and_development_expense: 'growthResearchAndDevelopmentExpenses', + growth_general_and_admin_expense: 'growthGeneralAndAdministrativeExpenses', + growth_selling_and_marketing_expense: 'growthSellingAndMarketingExpenses', +} + +const pctOrNull = z.number().nullable().default(null) + +export const FMPIncomeStatementGrowthDataSchema = IncomeStatementGrowthDataSchema.extend({ + symbol: z.string().nullable().default(null).describe('The stock ticker symbol.'), + reported_currency: z.string().nullable().default(null).describe('The currency in which the financial data is reported.'), + growth_revenue: pctOrNull.describe('Growth rate of total revenue.'), + growth_cost_of_revenue: pctOrNull.describe('Growth rate of cost of goods sold.'), + growth_gross_profit: pctOrNull.describe('Growth rate of gross profit.'), + growth_gross_profit_margin: pctOrNull.describe('Growth rate of gross profit as a percentage of revenue.'), + growth_general_and_admin_expense: pctOrNull.describe('Growth rate of general and administrative expenses.'), + growth_research_and_development_expense: pctOrNull.describe('Growth rate of expenses on research and development.'), + growth_selling_and_marketing_expense: pctOrNull.describe('Growth rate of expenses on selling and marketing activities.'), + growth_other_expenses: pctOrNull.describe('Growth rate of other operating expenses.'), + growth_operating_expenses: pctOrNull.describe('Growth rate of total operating expenses.'), + growth_cost_and_expenses: pctOrNull.describe('Growth rate of total costs and expenses.'), + growth_depreciation_and_amortization: pctOrNull.describe('Growth rate of depreciation and amortization expenses.'), + growth_interest_income: pctOrNull.describe('Growth rate of interest income.'), + growth_interest_expense: pctOrNull.describe('Growth rate of interest expenses.'), + growth_net_interest_income: pctOrNull.describe('Growth rate of net interest income.'), + growth_ebit: pctOrNull.describe('Growth rate of Earnings Before Interest and Taxes (EBIT).'), + growth_ebitda: pctOrNull.describe('Growth rate of EBITDA.'), + growth_operating_income: pctOrNull.describe('Growth rate of operating income.'), + growth_non_operating_income_excluding_interest: pctOrNull.describe('Growth rate of non-operating income excluding interest.'), + growth_total_other_income_expenses_net: pctOrNull.describe('Growth rate of net total other income and expenses.'), + growth_other_adjustments_to_net_income: pctOrNull.describe('Growth rate of other adjustments to net income.'), + growth_net_income_deductions: pctOrNull.describe('Growth rate of net income deductions.'), + growth_income_before_tax: pctOrNull.describe('Growth rate of income before taxes.'), + growth_income_tax_expense: pctOrNull.describe('Growth rate of income tax expenses.'), + growth_net_income_from_continuing_operations: pctOrNull.describe('Growth rate of net income from continuing operations.'), + growth_consolidated_net_income: pctOrNull.describe('Growth rate of net income.'), + growth_basic_earings_per_share: pctOrNull.describe('Growth rate of Earnings Per Share (EPS).'), + growth_diluted_earnings_per_share: pctOrNull.describe('Growth rate of diluted Earnings Per Share (EPS).'), + growth_weighted_average_basic_shares_outstanding: pctOrNull.describe('Growth rate of weighted average shares outstanding.'), + growth_weighted_average_diluted_shares_outstanding: pctOrNull.describe('Growth rate of diluted weighted average shares outstanding.'), +}).passthrough() + +export type FMPIncomeStatementGrowthData = z.infer + +// --- Fetcher --- + +export class FMPIncomeStatementGrowthFetcher extends Fetcher { + static override transformQuery(params: Record): FMPIncomeStatementGrowthQueryParams { + return FMPIncomeStatementGrowthQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPIncomeStatementGrowthQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const url = 'https://financialmodelingprep.com/stable/income-statement-growth' + + `?symbol=${query.symbol}` + + `&period=${query.period}` + + `&limit=${query.limit ?? 5}` + + `&apikey=${apiKey}` + return getDataMany(url) + } + + static override transformData( + query: FMPIncomeStatementGrowthQueryParams, + data: Record[], + ): FMPIncomeStatementGrowthData[] { + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPIncomeStatementGrowthDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/income-statement.ts b/packages/opentypebb/src/providers/fmp/models/income-statement.ts new file mode 100644 index 00000000..d8bd2e59 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/income-statement.ts @@ -0,0 +1,125 @@ +/** + * FMP Income Statement Model. + * Maps to: openbb_fmp/models/income_statement.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { IncomeStatementQueryParamsSchema, IncomeStatementDataSchema } from '../../../standard-models/income-statement.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPIncomeStatementQueryParamsSchema = IncomeStatementQueryParamsSchema.extend({ + period: z.enum(['q1', 'q2', 'q3', 'q4', 'fy', 'ttm', 'annual', 'quarter']).default('annual').describe('Time period of the data to return.'), +}) + +export type FMPIncomeStatementQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + period_ending: 'date', + fiscal_period: 'period', + fiscal_year: 'calendarYear', + filing_date: 'fillingDate', + accepted_date: 'acceptedDate', + reported_currency: 'reportedCurrency', + revenue: 'revenue', + cost_of_revenue: 'costOfRevenue', + gross_profit: 'grossProfit', + general_and_admin_expense: 'generalAndAdministrativeExpenses', + research_and_development_expense: 'researchAndDevelopmentExpenses', + selling_and_marketing_expense: 'sellingAndMarketingExpenses', + selling_general_and_admin_expense: 'sellingGeneralAndAdministrativeExpenses', + other_expenses: 'otherExpenses', + total_operating_expenses: 'operatingExpenses', + cost_and_expenses: 'costAndExpenses', + interest_income: 'interestIncome', + total_interest_expense: 'interestExpense', + depreciation_and_amortization: 'depreciationAndAmortization', + ebitda: 'ebitda', + total_operating_income: 'operatingIncome', + total_other_income_expenses: 'totalOtherIncomeExpensesNet', + total_pre_tax_income: 'incomeBeforeTax', + income_tax_expense: 'incomeTaxExpense', + consolidated_net_income: 'netIncome', + basic_earnings_per_share: 'eps', + diluted_earnings_per_share: 'epsDiluted', + weighted_average_basic_shares_outstanding: 'weightedAverageShsOut', + weighted_average_diluted_shares_outstanding: 'weightedAverageShsOutDil', +} + +const intOrNull = z.number().int().nullable().default(null) + +export const FMPIncomeStatementDataSchema = IncomeStatementDataSchema.extend({ + filing_date: z.string().nullable().default(null).describe('The date when the filing was made.'), + accepted_date: z.string().nullable().default(null).describe('The date and time when the filing was accepted.'), + cik: z.string().nullable().default(null).describe('The Central Index Key (CIK) assigned by the SEC.'), + symbol: z.string().nullable().default(null).describe('The stock ticker symbol.'), + reported_currency: z.string().nullable().default(null).describe('The currency in which the balance sheet was reported.'), + revenue: intOrNull.describe('Total revenue.'), + cost_of_revenue: intOrNull.describe('Cost of revenue.'), + gross_profit: intOrNull.describe('Gross profit.'), + general_and_admin_expense: intOrNull.describe('General and administrative expenses.'), + research_and_development_expense: intOrNull.describe('Research and development expenses.'), + selling_and_marketing_expense: intOrNull.describe('Selling and marketing expenses.'), + selling_general_and_admin_expense: intOrNull.describe('Selling, general and administrative expenses.'), + other_expenses: intOrNull.describe('Other expenses.'), + total_operating_expenses: intOrNull.describe('Total operating expenses.'), + cost_and_expenses: intOrNull.describe('Cost and expenses.'), + interest_income: intOrNull.describe('Interest income.'), + total_interest_expense: intOrNull.describe('Total interest expenses.'), + depreciation_and_amortization: intOrNull.describe('Depreciation and amortization.'), + ebitda: intOrNull.describe('EBITDA.'), + total_operating_income: intOrNull.describe('Total operating income.'), + total_other_income_expenses: intOrNull.describe('Total other income and expenses.'), + total_pre_tax_income: intOrNull.describe('Total pre-tax income.'), + income_tax_expense: intOrNull.describe('Income tax expense.'), + consolidated_net_income: intOrNull.describe('Consolidated net income.'), + basic_earnings_per_share: z.number().nullable().default(null).describe('Basic earnings per share.'), + diluted_earnings_per_share: z.number().nullable().default(null).describe('Diluted earnings per share.'), + weighted_average_basic_shares_outstanding: intOrNull.describe('Weighted average basic shares outstanding.'), + weighted_average_diluted_shares_outstanding: intOrNull.describe('Weighted average diluted shares outstanding.'), +}).passthrough() + +export type FMPIncomeStatementData = z.infer + +// --- Fetcher --- + +export class FMPIncomeStatementFetcher extends Fetcher { + static override transformQuery(params: Record): FMPIncomeStatementQueryParams { + return FMPIncomeStatementQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPIncomeStatementQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + let baseUrl = 'https://financialmodelingprep.com/stable/income-statement' + + if (query.period === 'ttm') { + baseUrl += '-ttm' + } + + const url = baseUrl + + `?symbol=${query.symbol}` + + (query.period !== 'ttm' ? `&period=${query.period}` : '') + + `&limit=${query.limit ?? 5}` + + `&apikey=${apiKey}` + + return getDataMany(url) + } + + static override transformData( + query: FMPIncomeStatementQueryParams, + data: Record[], + ): FMPIncomeStatementData[] { + return data.map((d) => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPIncomeStatementDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/index-constituents.ts b/packages/opentypebb/src/providers/fmp/models/index-constituents.ts new file mode 100644 index 00000000..d06cd00d --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/index-constituents.ts @@ -0,0 +1,70 @@ +/** + * FMP Index Constituents Model. + * Maps to: openbb_fmp/models/index_constituents.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { IndexConstituentsQueryParamsSchema, IndexConstituentsDataSchema } from '../../../standard-models/index-constituents.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + headquarter: 'headQuarter', + date_added: 'dateFirstAdded', + industry: 'subSector', + name: 'addedSecurity', + removed_symbol: 'removedTicker', + removed_name: 'removedSecurity', +} + +export const FMPIndexConstituentsQueryParamsSchema = IndexConstituentsQueryParamsSchema.extend({ + symbol: z.enum(['dowjones', 'sp500', 'nasdaq']).default('dowjones').describe('Index symbol.'), + historical: z.boolean().default(false).describe('Flag to retrieve historical removals and additions.'), +}) +export type FMPIndexConstituentsQueryParams = z.infer + +export const FMPIndexConstituentsDataSchema = IndexConstituentsDataSchema.extend({ + sector: z.string().nullable().default(null).describe('Sector classification.'), + industry: z.string().nullable().default(null).describe('Industry classification.'), + headquarter: z.string().nullable().default(null).describe('Location of headquarters.'), + date_added: z.string().nullable().default(null).describe('Date added to the index.'), + cik: z.string().nullable().default(null).describe('CIK number.'), + founded: z.string().nullable().default(null).describe('When the company was founded.'), + removed_symbol: z.string().nullable().default(null).describe('Symbol of the company removed.'), + removed_name: z.string().nullable().default(null).describe('Name of the company removed.'), + reason: z.string().nullable().default(null).describe('Reason for the removal.'), + date: z.string().nullable().default(null).describe('Date of the historical constituent data.'), +}).passthrough() +export type FMPIndexConstituentsData = z.infer + +export class FMPIndexConstituentsFetcher extends Fetcher { + static override transformQuery(params: Record): FMPIndexConstituentsQueryParams { + return FMPIndexConstituentsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPIndexConstituentsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const prefix = query.historical ? 'historical-' : '' + return getDataMany( + `https://financialmodelingprep.com/stable/${prefix}${query.symbol}-constituent/?apikey=${apiKey}`, + ) + } + + static override transformData( + _query: FMPIndexConstituentsQueryParams, + data: Record[], + ): FMPIndexConstituentsData[] { + return data.map(d => { + // Clean empty strings + for (const key of ['removed_symbol', 'removed_name', 'reason', 'removedTicker', 'removedSecurity']) { + if (d[key] === '' || d[key] === "''" || d[key] === 'None') d[key] = null + } + const aliased = applyAliases(d, ALIAS_DICT) + return FMPIndexConstituentsDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/index-historical.ts b/packages/opentypebb/src/providers/fmp/models/index-historical.ts new file mode 100644 index 00000000..26238afc --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/index-historical.ts @@ -0,0 +1,81 @@ +/** + * FMP Index Historical Model. + * Maps to: openbb_fmp/models/index_historical.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { IndexHistoricalQueryParamsSchema, IndexHistoricalDataSchema } from '../../../standard-models/index-historical.js' +import { getHistoricalOhlc } from '../utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' + +export const FMPIndexHistoricalQueryParamsSchema = IndexHistoricalQueryParamsSchema.extend({ + interval: z.enum(['1m', '5m', '1h', '1d']).default('1d').describe('Time interval of the data.'), +}) +export type FMPIndexHistoricalQueryParams = z.infer + +export const FMPIndexHistoricalDataSchema = IndexHistoricalDataSchema.extend({ + vwap: z.number().nullable().default(null).describe('Volume-weighted average price.'), + change: z.number().nullable().default(null).describe('Change in the price from the previous close.'), + change_percent: z.number().nullable().default(null).describe('Change percent from previous close.'), +}).passthrough() +export type FMPIndexHistoricalData = z.infer + +export class FMPIndexHistoricalFetcher extends Fetcher { + static override transformQuery(params: Record): FMPIndexHistoricalQueryParams { + // Default start_date to 1 year ago, end_date to today + const now = new Date() + if (!params.start_date) { + const oneYearAgo = new Date(now) + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) + params.start_date = oneYearAgo.toISOString().split('T')[0] + } + if (!params.end_date) { + params.end_date = now.toISOString().split('T')[0] + } + return FMPIndexHistoricalQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPIndexHistoricalQueryParams, + credentials: Record | null, + ): Promise[]> { + return getHistoricalOhlc( + { + symbol: query.symbol, + interval: query.interval, + start_date: query.start_date, + end_date: query.end_date, + }, + credentials, + ) + } + + static override transformData( + query: FMPIndexHistoricalQueryParams, + data: Record[], + ): FMPIndexHistoricalData[] { + if (!data || data.length === 0) { + throw new EmptyDataError() + } + + // Normalize change_percent + for (const d of data) { + if (typeof d.changePercentage === 'number') { + d.changePercentage = d.changePercentage / 100 + } + if (typeof d.change_percent === 'number') { + d.change_percent = d.change_percent / 100 + } + } + + // Sort by date ascending + const sorted = data.sort((a, b) => { + const da = String(a.date ?? '') + const db = String(b.date ?? '') + return da.localeCompare(db) + }) + + return sorted.map(d => FMPIndexHistoricalDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/insider-trading.ts b/packages/opentypebb/src/providers/fmp/models/insider-trading.ts new file mode 100644 index 00000000..bddc5a04 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/insider-trading.ts @@ -0,0 +1,102 @@ +/** + * FMP Insider Trading Model. + * Maps to: openbb_fmp/models/insider_trading.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { InsiderTradingQueryParamsSchema, InsiderTradingDataSchema } from '../../../standard-models/insider-trading.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany, getDataUrls, getQueryString } from '../utils/helpers.js' +import { TRANSACTION_TYPES_DICT } from '../utils/definitions.js' + +// --- Query Params --- + +export const FMPInsiderTradingQueryParamsSchema = InsiderTradingQueryParamsSchema.extend({ + transaction_type: z.string().nullable().default(null).describe('Type of the transaction.'), + statistics: z.boolean().default(false).describe('Flag to return summary statistics for the given symbol.'), +}) + +export type FMPInsiderTradingQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + owner_cik: 'reportingCik', + owner_name: 'reportingName', + owner_title: 'typeOfOwner', + ownership_type: 'directOrIndirect', + security_type: 'securityName', + transaction_price: 'price', + acquisition_or_disposition: 'acquistionOrDisposition', + filing_url: 'link', + company_cik: 'cik', +} + +export const FMPInsiderTradingDataSchema = InsiderTradingDataSchema.extend({ + form_type: z.string().nullable().default(null).describe('The SEC form type.'), + year: z.number().int().nullable().default(null).describe('The calendar year for the statistics.'), + quarter: z.number().int().nullable().default(null).describe('The calendar quarter for the statistics.'), +}).passthrough() + +export type FMPInsiderTradingData = z.infer + +// --- Fetcher --- + +export class FMPInsiderTradingFetcher extends Fetcher { + static override transformQuery(params: Record): FMPInsiderTradingQueryParams { + return FMPInsiderTradingQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPInsiderTradingQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + + if (query.statistics) { + const url = `https://financialmodelingprep.com/stable/insider-trading/statistics?symbol=${query.symbol}&apikey=${apiKey}` + return getDataMany(url) + } + + const transactionType = query.transaction_type + ? TRANSACTION_TYPES_DICT[query.transaction_type] ?? null + : null + + const limit = query.limit && query.limit <= 1000 ? query.limit : 1000 + const baseUrl = 'https://financialmodelingprep.com/stable/insider-trading/search' + + const queryParams: Record = { + symbol: query.symbol, + transactionType: transactionType, + } + const queryStr = getQueryString(queryParams, ['page', 'limit']) + + const pages = Math.ceil(limit / 1000) + const urls = Array.from({ length: pages }, (_, page) => + `${baseUrl}?${queryStr}&page=${page}&limit=${limit}&apikey=${apiKey}`, + ) + + const results = await getDataUrls[]>(urls) + return results.flat() + } + + static override transformData( + query: FMPInsiderTradingQueryParams, + data: Record[], + ): FMPInsiderTradingData[] { + const sorted = query.statistics + ? [...data].sort((a, b) => { + const yearDiff = Number(b.year ?? 0) - Number(a.year ?? 0) + return yearDiff !== 0 ? yearDiff : Number(b.quarter ?? 0) - Number(a.quarter ?? 0) + }) + : [...data].sort((a, b) => + String(b.filingDate ?? '').localeCompare(String(a.filingDate ?? '')), + ) + + return sorted.map((d) => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPInsiderTradingDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/institutional-ownership.ts b/packages/opentypebb/src/providers/fmp/models/institutional-ownership.ts new file mode 100644 index 00000000..1694fff7 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/institutional-ownership.ts @@ -0,0 +1,136 @@ +/** + * FMP Institutional Ownership Model. + * Maps to: openbb_fmp/models/institutional_ownership.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { InstitutionalOwnershipQueryParamsSchema, InstitutionalOwnershipDataSchema } from '../../../standard-models/institutional-ownership.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + number_of_13f_shares: 'numberOf13Fshares', + last_number_of_13f_shares: 'lastNumberOf13Fshares', + number_of_13f_shares_change: 'numberOf13FsharesChange', + ownership_percent_change: 'changeInOwnershipPercentage', +} + +const numOrNull = z.number().nullable().default(null) + +export const FMPInstitutionalOwnershipQueryParamsSchema = InstitutionalOwnershipQueryParamsSchema.extend({ + year: z.coerce.number().nullable().default(null).describe('Calendar year for the data. If not provided, the latest year is used.'), + quarter: z.coerce.number().nullable().default(null).describe('Calendar quarter for the data (1-4). If not provided, the quarter previous to the current quarter is used.'), +}) +export type FMPInstitutionalOwnershipQueryParams = z.infer + +export const FMPInstitutionalOwnershipDataSchema = InstitutionalOwnershipDataSchema.extend({ + investors_holding: z.number().describe('Number of investors holding the stock.'), + last_investors_holding: z.number().describe('Number of investors holding the stock in the last quarter.'), + investors_holding_change: z.number().describe('Change in the number of investors holding the stock.'), + number_of_13f_shares: numOrNull.describe('Number of 13F shares.'), + last_number_of_13f_shares: numOrNull.describe('Number of 13F shares in the last quarter.'), + number_of_13f_shares_change: numOrNull.describe('Change in the number of 13F shares.'), + total_invested: z.number().describe('Total amount invested.'), + last_total_invested: z.number().describe('Total amount invested in the last quarter.'), + total_invested_change: z.number().describe('Change in the total amount invested.'), + ownership_percent: numOrNull.describe('Ownership percent as a normalized percent.'), + last_ownership_percent: numOrNull.describe('Ownership percent in the last quarter.'), + ownership_percent_change: numOrNull.describe('Change in the ownership percent.'), + new_positions: z.number().describe('Number of new positions.'), + last_new_positions: z.number().describe('Number of new positions in the last quarter.'), + new_positions_change: z.number().describe('Change in the number of new positions.'), + increased_positions: z.number().describe('Number of increased positions.'), + last_increased_positions: z.number().describe('Number of increased positions in the last quarter.'), + increased_positions_change: z.number().describe('Change in the number of increased positions.'), + closed_positions: z.number().describe('Number of closed positions.'), + last_closed_positions: z.number().describe('Number of closed positions in the last quarter.'), + closed_positions_change: z.number().describe('Change in the number of closed positions.'), + reduced_positions: z.number().describe('Number of reduced positions.'), + last_reduced_positions: z.number().describe('Number of reduced positions in the last quarter.'), + reduced_positions_change: z.number().describe('Change in the number of reduced positions.'), + total_calls: z.number().describe('Total number of call options contracts traded.'), + last_total_calls: z.number().describe('Total number of call options contracts traded in last quarter.'), + total_calls_change: z.number().describe('Change in the total number of call options contracts.'), + total_puts: z.number().describe('Total number of put options contracts traded.'), + last_total_puts: z.number().describe('Total number of put options contracts traded in last quarter.'), + total_puts_change: z.number().describe('Change in the total number of put options contracts.'), + put_call_ratio: z.number().describe('Put-call ratio.'), + last_put_call_ratio: z.number().describe('Put-call ratio in the last quarter.'), + put_call_ratio_change: z.number().describe('Change in the put-call ratio.'), +}).passthrough() +export type FMPInstitutionalOwnershipData = z.infer + +/** Get current quarter info for default year/quarter */ +function getCurrentQuarterInfo(): { year: number; quarter: number } { + const now = new Date() + const currentQuarter = Math.ceil((now.getMonth() + 1) / 3) + // Use previous quarter + if (currentQuarter === 1) { + return { year: now.getFullYear() - 1, quarter: 4 } + } + return { year: now.getFullYear(), quarter: currentQuarter - 1 } +} + +export class FMPInstitutionalOwnershipFetcher extends Fetcher { + static override transformQuery(params: Record): FMPInstitutionalOwnershipQueryParams { + return FMPInstitutionalOwnershipQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPInstitutionalOwnershipQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const symbols = query.symbol.split(',').map(s => s.trim()).filter(Boolean) + + let year = query.year + let quarter = query.quarter + + if (year == null && quarter == null) { + const info = getCurrentQuarterInfo() + year = info.year + quarter = info.quarter + } else if (year == null) { + year = new Date().getFullYear() + } else if (quarter == null) { + const now = new Date() + quarter = year < now.getFullYear() + ? 4 + : Math.max(1, Math.ceil((now.getMonth() + 1) / 3) - 1) + } + + const results: Record[] = [] + const settled = await Promise.allSettled( + symbols.map(symbol => + getDataMany( + `https://financialmodelingprep.com/stable/institutional-ownership/symbol-positions-summary?symbol=${symbol}&year=${year}&quarter=${quarter}&apikey=${apiKey}`, + ), + ), + ) + + for (const r of settled) { + if (r.status === 'fulfilled' && r.value?.length) { + results.push(...r.value) + } + } + + return results + } + + static override transformData( + _query: FMPInstitutionalOwnershipQueryParams, + data: Record[], + ): FMPInstitutionalOwnershipData[] { + return data.map(d => { + // Normalize percent fields from whole numbers to decimal + for (const key of ['ownershipPercent', 'lastOwnershipPercent', 'changeInOwnershipPercentage']) { + if (typeof d[key] === 'number') { + d[key] = (d[key] as number) / 100 + } + } + const aliased = applyAliases(d, ALIAS_DICT) + return FMPInstitutionalOwnershipDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/key-executives.ts b/packages/opentypebb/src/providers/fmp/models/key-executives.ts new file mode 100644 index 00000000..d3b3e2e9 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/key-executives.ts @@ -0,0 +1,39 @@ +/** + * FMP Key Executives Model. + * Maps to: openbb_fmp/models/key_executives.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { KeyExecutivesQueryParamsSchema, KeyExecutivesDataSchema } from '../../../standard-models/key-executives.js' +import { getDataMany } from '../utils/helpers.js' + +export const FMPKeyExecutivesQueryParamsSchema = KeyExecutivesQueryParamsSchema +export type FMPKeyExecutivesQueryParams = z.infer + +// extra="ignore" in Python → .strip() in Zod +export const FMPKeyExecutivesDataSchema = KeyExecutivesDataSchema.strip() +export type FMPKeyExecutivesData = z.infer + +export class FMPKeyExecutivesFetcher extends Fetcher { + static override transformQuery(params: Record): FMPKeyExecutivesQueryParams { + return FMPKeyExecutivesQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPKeyExecutivesQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + return getDataMany( + `https://financialmodelingprep.com/stable/key-executives?symbol=${query.symbol}&apikey=${apiKey}`, + ) + } + + static override transformData( + _query: FMPKeyExecutivesQueryParams, + data: Record[], + ): FMPKeyExecutivesData[] { + return data.map(d => FMPKeyExecutivesDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/key-metrics.ts b/packages/opentypebb/src/providers/fmp/models/key-metrics.ts new file mode 100644 index 00000000..2de0cafd --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/key-metrics.ts @@ -0,0 +1,135 @@ +/** + * FMP Key Metrics Model. + * Maps to: openbb_fmp/models/key_metrics.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { KeyMetricsQueryParamsSchema, KeyMetricsDataSchema } from '../../../standard-models/key-metrics.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getDataMany } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPKeyMetricsQueryParamsSchema = KeyMetricsQueryParamsSchema.extend({ + ttm: z.enum(['include', 'exclude', 'only']).default('only').describe("Specify whether to include, exclude, or only show TTM data."), + period: z.enum(['q1', 'q2', 'q3', 'q4', 'fy', 'annual', 'quarter']).default('annual').describe('Specify the fiscal period for the data.'), + limit: z.number().int().nullable().default(null).describe('Number of most recent reporting periods to return.'), +}) + +export type FMPKeyMetricsQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + period_ending: 'date', + fiscal_period: 'period', + currency: 'reportedCurrency', +} + +const TTM_ALIAS_DICT: Record = { + period_ending: 'date', + fiscal_period: 'fiscal_period', + currency: 'reportedCurrency', + enterprise_value: 'enterpriseValueTTM', + ev_to_sales: 'evToSalesTTM', + ev_to_ebitda: 'evToEBITDATTM', + return_on_equity: 'returnOnEquityTTM', + return_on_assets: 'returnOnAssetsTTM', + return_on_invested_capital: 'returnOnInvestedCapitalTTM', + current_ratio: 'currentRatioTTM', +} + +export const FMPKeyMetricsDataSchema = KeyMetricsDataSchema.extend({ + enterprise_value: z.number().nullable().default(null).describe('Enterprise Value.'), + ev_to_sales: z.number().nullable().default(null).describe('Enterprise Value to Sales ratio.'), + ev_to_ebitda: z.number().nullable().default(null).describe('Enterprise Value to EBITDA ratio.'), + return_on_equity: z.number().nullable().default(null).describe('Return on Equity.'), + return_on_assets: z.number().nullable().default(null).describe('Return on Assets.'), + return_on_invested_capital: z.number().nullable().default(null).describe('Return on Invested Capital.'), + current_ratio: z.number().nullable().default(null).describe('Current Ratio.'), +}).passthrough() + +export type FMPKeyMetricsData = z.infer + +// --- Fetcher --- + +export class FMPKeyMetricsFetcher extends Fetcher { + static override transformQuery(params: Record): FMPKeyMetricsQueryParams { + return FMPKeyMetricsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPKeyMetricsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const symbols = query.symbol.split(',') + const results: Record[] = [] + const baseUrl = 'https://financialmodelingprep.com/stable/key-metrics' + const limit = query.limit && query.ttm !== 'only' ? query.limit : 1 + + const getOne = async (symbol: string) => { + try { + const ttmUrl = `${baseUrl}-ttm?symbol=${symbol}&apikey=${apiKey}` + const metricsUrl = `${baseUrl}?symbol=${symbol}&period=${query.period}&limit=${limit}&apikey=${apiKey}` + + const [ttmData, metricsData] = await Promise.all([ + getDataMany(ttmUrl).catch(() => []), + getDataMany(metricsUrl).catch(() => []), + ]) + + const result: Record[] = [] + let currency: string | null = null + + if (metricsData.length > 0) { + if (query.ttm !== 'only') { + result.push(...metricsData) + } + currency = metricsData[0].reportedCurrency as string ?? null + } + + if (ttmData.length > 0 && query.ttm !== 'exclude') { + const ttmResult = { ...ttmData[0] } + ttmResult.date = new Date().toISOString().split('T')[0] + ttmResult.fiscal_period = 'TTM' + ttmResult.fiscal_year = new Date().getFullYear() + if (currency) ttmResult.reportedCurrency = currency + result.unshift(ttmResult) + } + + if (result.length > 0) { + results.push(...result) + } else { + console.warn(`Symbol Error: No data found for ${symbol}.`) + } + } catch { + console.warn(`Symbol Error: No data found for ${symbol}.`) + } + } + + await Promise.all(symbols.map(getOne)) + + if (results.length === 0) { + throw new EmptyDataError('No data found for given symbols.') + } + + return results + } + + static override transformData( + query: FMPKeyMetricsQueryParams, + data: Record[], + ): FMPKeyMetricsData[] { + const sorted = [...data].sort((a, b) => + String(b.date ?? '').localeCompare(String(a.date ?? '')), + ) + + return sorted.map((d) => { + const isTTM = d.fiscal_period === 'TTM' + const aliased = applyAliases(d, isTTM ? TTM_ALIAS_DICT : ALIAS_DICT) + return FMPKeyMetricsDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/losers.ts b/packages/opentypebb/src/providers/fmp/models/losers.ts new file mode 100644 index 00000000..fc4e8891 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/losers.ts @@ -0,0 +1,49 @@ +/** + * FMP Top Losers Model. + * Maps to: openbb_fmp/models/equity_losers.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityPerformanceQueryParamsSchema, EquityPerformanceDataSchema } from '../../../standard-models/equity-performance.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { percent_change: 'changesPercentage' } + +export const FMPLosersQueryParamsSchema = EquityPerformanceQueryParamsSchema +export type FMPLosersQueryParams = z.infer + +export const FMPLosersDataSchema = EquityPerformanceDataSchema.extend({ + exchange: z.string().describe('Stock exchange where the security is listed.'), +}).passthrough() +export type FMPLosersData = z.infer + +export class FMPLosersFetcher extends Fetcher { + static override transformQuery(params: Record): FMPLosersQueryParams { + return FMPLosersQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPLosersQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + return getDataMany(`https://financialmodelingprep.com/stable/biggest-losers?apikey=${apiKey}`) + } + + static override transformData( + query: FMPLosersQueryParams, + data: Record[], + ): FMPLosersData[] { + const sorted = [...data].sort((a, b) => { + const diff = Number(b.changesPercentage ?? 0) - Number(a.changesPercentage ?? 0) + return query.sort === 'desc' ? diff : -diff + }) + return sorted.map((d) => { + const aliased = applyAliases(d, ALIAS_DICT) + if (typeof aliased.percent_change === 'number') aliased.percent_change = aliased.percent_change / 100 + return FMPLosersDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/market-snapshots.ts b/packages/opentypebb/src/providers/fmp/models/market-snapshots.ts new file mode 100644 index 00000000..b7852fbd --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/market-snapshots.ts @@ -0,0 +1,117 @@ +/** + * FMP Market Snapshots Model. + * Maps to: openbb_fmp/models/market_snapshots.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { MarketSnapshotsQueryParamsSchema, MarketSnapshotsDataSchema } from '../../../standard-models/market-snapshots.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' + +const ALIAS_DICT: Record = { + high: 'dayHigh', + low: 'dayLow', + prev_close: 'previousClose', + change_percent: 'changePercentage', + close: 'price', + last_price_timestamp: 'timestamp', + ma50: 'priceAvg50', + ma200: 'priceAvg200', + year_high: 'yearHigh', + year_low: 'yearLow', + market_cap: 'marketCap', +} + +const numOrNull = z.number().nullable().default(null) + +export const FMPMarketSnapshotsQueryParamsSchema = MarketSnapshotsQueryParamsSchema.extend({ + market: z.string().default('nasdaq').describe('The market to fetch data for (e.g., nasdaq, nyse, etf, crypto, forex, index, commodity, mutual_fund).'), +}) +export type FMPMarketSnapshotsQueryParams = z.infer + +export const FMPMarketSnapshotsDataSchema = MarketSnapshotsDataSchema.extend({ + ma50: numOrNull.describe('The 50-day moving average.'), + ma200: numOrNull.describe('The 200-day moving average.'), + year_high: numOrNull.describe('The 52-week high.'), + year_low: numOrNull.describe('The 52-week low.'), + market_cap: numOrNull.describe('Market cap of the stock.'), + last_price_timestamp: z.string().nullable().default(null).describe('The timestamp of the last price.'), +}).passthrough() +export type FMPMarketSnapshotsData = z.infer + +export class FMPMarketSnapshotsFetcher extends Fetcher { + static override transformQuery(params: Record): FMPMarketSnapshotsQueryParams { + return FMPMarketSnapshotsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPMarketSnapshotsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const baseUrl = 'https://financialmodelingprep.com/stable/batch-' + const market = query.market.toUpperCase() + + let url: string + if (market === 'ETF') { + url = `${baseUrl}etf-quotes?short=false&apikey=${apiKey}` + } else if (market === 'MUTUAL_FUND') { + url = `${baseUrl}mutualfund-quotes?short=false&apikey=${apiKey}` + } else if (market === 'FOREX') { + url = `${baseUrl}forex-quotes?short=false&apikey=${apiKey}` + } else if (market === 'CRYPTO') { + url = `${baseUrl}crypto-quotes?short=false&apikey=${apiKey}` + } else if (market === 'INDEX') { + url = `${baseUrl}index-quotes?short=false&apikey=${apiKey}` + } else if (market === 'COMMODITY') { + url = `${baseUrl}commodity-quotes?short=false&apikey=${apiKey}` + } else { + url = `${baseUrl}exchange-quote?exchange=${market}&short=false&apikey=${apiKey}` + } + + return getDataMany(url) + } + + static override transformData( + _query: FMPMarketSnapshotsQueryParams, + data: Record[], + ): FMPMarketSnapshotsData[] { + if (!data || data.length === 0) { + throw new EmptyDataError('No data was returned') + } + + // Filter to most recent day only (based on timestamp) + const withTimestamps = data.filter(d => typeof d.timestamp === 'number') + if (withTimestamps.length > 0) { + const maxTs = Math.max(...withTimestamps.map(d => d.timestamp as number)) + const maxDate = new Date(maxTs * 1000).toISOString().split('T')[0] + data = data.filter(d => { + if (typeof d.timestamp !== 'number') return false + const itemDate = new Date((d.timestamp as number) * 1000).toISOString().split('T')[0] + return itemDate === maxDate + }) + } + + // Sort by timestamp descending + data.sort((a, b) => ((b.timestamp as number) ?? 0) - ((a.timestamp as number) ?? 0)) + + return data.map(d => { + // Normalize change_percent + if (typeof d.changePercentage === 'number') { + d.changePercentage = d.changePercentage / 100 + } + // Convert Unix timestamp to ISO string + if (typeof d.timestamp === 'number') { + d.timestamp = new Date(d.timestamp * 1000).toISOString() + } + // Clean empty name strings + if (d.name === '' || d.name === "''" || d.name === ' ') { + d.name = null + } + const aliased = applyAliases(d, ALIAS_DICT) + return FMPMarketSnapshotsDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/price-performance.ts b/packages/opentypebb/src/providers/fmp/models/price-performance.ts new file mode 100644 index 00000000..a2bc31cc --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/price-performance.ts @@ -0,0 +1,77 @@ +/** + * FMP Price Performance Model. + * Maps to: openbb_fmp/models/price_performance.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { RecentPerformanceQueryParamsSchema, RecentPerformanceDataSchema } from '../../../standard-models/recent-performance.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + one_day: '1D', + one_week: '5D', + one_month: '1M', + three_month: '3M', + six_month: '6M', + one_year: '1Y', + three_year: '3Y', + five_year: '5Y', + ten_year: '10Y', +} + +export const FMPPricePerformanceQueryParamsSchema = RecentPerformanceQueryParamsSchema +export type FMPPricePerformanceQueryParams = z.infer + +export const FMPPricePerformanceDataSchema = RecentPerformanceDataSchema +export type FMPPricePerformanceData = z.infer + +export class FMPPricePerformanceFetcher extends Fetcher { + static override transformQuery(params: Record): FMPPricePerformanceQueryParams { + return FMPPricePerformanceQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPPricePerformanceQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const symbols = query.symbol.toUpperCase().split(',') + // Chunk by 200 (FMP limit) + const chunkSize = 200 + const allResults: Record[] = [] + for (let i = 0; i < symbols.length; i += chunkSize) { + const chunk = symbols.slice(i, i + chunkSize) + const url = `https://financialmodelingprep.com/stable/stock-price-change?symbol=${chunk.join(',')}&apikey=${apiKey}` + try { + const data = await getDataMany(url) + allResults.push(...data) + } catch { + // If a chunk fails, continue with others + } + } + if (allResults.length === 0) { + return getDataMany( + `https://financialmodelingprep.com/stable/stock-price-change?symbol=${query.symbol.toUpperCase()}&apikey=${apiKey}`, + ) + } + return allResults + } + + static override transformData( + _query: FMPPricePerformanceQueryParams, + data: Record[], + ): FMPPricePerformanceData[] { + return data.map(d => { + // Replace zero with null and convert percents to normalized values + for (const [key, value] of Object.entries(d)) { + if (key !== 'symbol') { + d[key] = value === 0 ? null : typeof value === 'number' ? value / 100 : value + } + } + const aliased = applyAliases(d, ALIAS_DICT) + return FMPPricePerformanceDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/price-target-consensus.ts b/packages/opentypebb/src/providers/fmp/models/price-target-consensus.ts new file mode 100644 index 00000000..aa2ebc1a --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/price-target-consensus.ts @@ -0,0 +1,76 @@ +/** + * FMP Price Target Consensus Model. + * Maps to: openbb_fmp/models/price_target_consensus.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { PriceTargetConsensusQueryParamsSchema, PriceTargetConsensusDataSchema } from '../../../standard-models/price-target-consensus.js' +import { applyAliases, amakeRequest } from '../../../core/provider/utils/helpers.js' +import { OpenBBError, EmptyDataError } from '../../../core/provider/utils/errors.js' +import { responseCallback } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPPriceTargetConsensusQueryParamsSchema = PriceTargetConsensusQueryParamsSchema + +export type FMPPriceTargetConsensusQueryParams = z.infer + +// --- Data --- + +export const FMPPriceTargetConsensusDataSchema = PriceTargetConsensusDataSchema + +export type FMPPriceTargetConsensusData = z.infer + +// --- Fetcher --- + +export class FMPPriceTargetConsensusFetcher extends Fetcher { + static override transformQuery(params: Record): FMPPriceTargetConsensusQueryParams { + if (!params.symbol) { + throw new OpenBBError('Symbol is a required field for FMP.') + } + return FMPPriceTargetConsensusQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPPriceTargetConsensusQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const symbols = (query.symbol ?? '').split(',') + const results: Record[] = [] + + const getOne = async (symbol: string) => { + const url = `https://financialmodelingprep.com/stable/price-target-consensus?symbol=${symbol}&apikey=${apiKey}` + try { + const result = await amakeRequest[]>(url, { responseCallback }) + if (result && result.length > 0) { + results.push(...result) + } else { + console.warn(`Symbol Error: No data found for ${symbol}`) + } + } catch { + console.warn(`Symbol Error: No data found for ${symbol}`) + } + } + + await Promise.all(symbols.map(getOne)) + + if (results.length === 0) { + throw new EmptyDataError('No data returned for the given symbols.') + } + + return results.sort((a, b) => { + const ai = symbols.indexOf(String(a.symbol ?? '')) + const bi = symbols.indexOf(String(b.symbol ?? '')) + return ai - bi + }) + } + + static override transformData( + query: FMPPriceTargetConsensusQueryParams, + data: Record[], + ): FMPPriceTargetConsensusData[] { + return data.map((d) => FMPPriceTargetConsensusDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/price-target.ts b/packages/opentypebb/src/providers/fmp/models/price-target.ts new file mode 100644 index 00000000..2f47327e --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/price-target.ts @@ -0,0 +1,62 @@ +/** + * FMP Price Target Model. + * Maps to: openbb_fmp/models/price_target.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { PriceTargetQueryParamsSchema, PriceTargetDataSchema } from '../../../standard-models/price-target.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPPriceTargetQueryParamsSchema = PriceTargetQueryParamsSchema.extend({}) +export type FMPPriceTargetQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + analyst_firm: 'analystCompany', + rating_current: 'newGrade', + rating_previous: 'previousGrade', + news_title: 'newsTitle', + news_url: 'newsURL', +} + +export const FMPPriceTargetDataSchema = PriceTargetDataSchema.extend({ + news_title: z.string().nullable().default(null).describe('Title of the associated news.'), + news_url: z.string().nullable().default(null).describe('URL of the associated news.'), +}).passthrough() + +export type FMPPriceTargetData = z.infer + +// --- Fetcher --- + +export class FMPPriceTargetFetcher extends Fetcher { + static override transformQuery(params: Record): FMPPriceTargetQueryParams { + return FMPPriceTargetQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPPriceTargetQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const url = 'https://financialmodelingprep.com/stable/price-target' + + `?symbol=${query.symbol}` + + (query.limit ? `&limit=${query.limit}` : '') + + `&apikey=${apiKey}` + return getDataMany(url) + } + + static override transformData( + query: FMPPriceTargetQueryParams, + data: Record[], + ): FMPPriceTargetData[] { + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPPriceTargetDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/revenue-business-line.ts b/packages/opentypebb/src/providers/fmp/models/revenue-business-line.ts new file mode 100644 index 00000000..71b7fedf --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/revenue-business-line.ts @@ -0,0 +1,79 @@ +/** + * FMP Revenue By Business Line Model. + * Maps to: openbb_fmp/models/revenue_business_line.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { RevenueBusinessLineQueryParamsSchema, RevenueBusinessLineDataSchema } from '../../../standard-models/revenue-business-line.js' +import { getDataMany } from '../utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' + +export const FMPRevenueBusinessLineQueryParamsSchema = RevenueBusinessLineQueryParamsSchema.extend({ + period: z.enum(['quarter', 'annual']).default('annual').describe('Fiscal period.'), +}) +export type FMPRevenueBusinessLineQueryParams = z.infer + +export const FMPRevenueBusinessLineDataSchema = RevenueBusinessLineDataSchema +export type FMPRevenueBusinessLineData = z.infer + +export class FMPRevenueBusinessLineFetcher extends Fetcher { + static override transformQuery(params: Record): FMPRevenueBusinessLineQueryParams { + return FMPRevenueBusinessLineQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPRevenueBusinessLineQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + return getDataMany( + `https://financialmodelingprep.com/stable/revenue-product-segmentation?symbol=${query.symbol}&period=${query.period}&structure=flat&apikey=${apiKey}`, + ) + } + + static override transformData( + _query: FMPRevenueBusinessLineQueryParams, + data: Record[], + ): FMPRevenueBusinessLineData[] { + if (!data || data.length === 0) { + throw new EmptyDataError('The request was returned empty.') + } + + const results: FMPRevenueBusinessLineData[] = [] + + for (const item of data) { + const periodEnding = item.date as string | undefined + const fiscalYear = item.fiscalYear as number | undefined + const fiscalPeriod = item.period as string | undefined + const segment = (item.data ?? {}) as Record + + for (const [businessLine, revenueValue] of Object.entries(segment)) { + if (revenueValue != null) { + const revenue = Number(revenueValue) + if (!isNaN(revenue)) { + results.push( + FMPRevenueBusinessLineDataSchema.parse({ + period_ending: periodEnding, + fiscal_year: fiscalYear, + fiscal_period: fiscalPeriod, + business_line: businessLine.trim(), + revenue, + }), + ) + } + } + } + } + + if (results.length === 0) { + throw new EmptyDataError('Unknown error while transforming the data.') + } + + return results.sort((a, b) => { + const dateComp = String(a.period_ending ?? '').localeCompare(String(b.period_ending ?? '')) + if (dateComp !== 0) return dateComp + return (a.revenue ?? 0) - (b.revenue ?? 0) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/revenue-geographic.ts b/packages/opentypebb/src/providers/fmp/models/revenue-geographic.ts new file mode 100644 index 00000000..a2800d13 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/revenue-geographic.ts @@ -0,0 +1,79 @@ +/** + * FMP Revenue Geographic Model. + * Maps to: openbb_fmp/models/revenue_geographic.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { RevenueGeographicQueryParamsSchema, RevenueGeographicDataSchema } from '../../../standard-models/revenue-geographic.js' +import { getDataMany } from '../utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' + +export const FMPRevenueGeographicQueryParamsSchema = RevenueGeographicQueryParamsSchema.extend({ + period: z.enum(['quarter', 'annual']).default('annual').describe('Fiscal period.'), +}) +export type FMPRevenueGeographicQueryParams = z.infer + +export const FMPRevenueGeographicDataSchema = RevenueGeographicDataSchema +export type FMPRevenueGeographicData = z.infer + +export class FMPRevenueGeographicFetcher extends Fetcher { + static override transformQuery(params: Record): FMPRevenueGeographicQueryParams { + return FMPRevenueGeographicQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPRevenueGeographicQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + return getDataMany( + `https://financialmodelingprep.com/stable/revenue-geographic-segmentation?symbol=${query.symbol}&period=${query.period}&structure=flat&apikey=${apiKey}`, + ) + } + + static override transformData( + _query: FMPRevenueGeographicQueryParams, + data: Record[], + ): FMPRevenueGeographicData[] { + if (!data || data.length === 0) { + throw new EmptyDataError('The request was returned empty.') + } + + const results: FMPRevenueGeographicData[] = [] + + for (const item of data) { + const periodEnding = item.date as string | undefined + const fiscalYear = item.fiscalYear as number | undefined + const fiscalPeriod = item.period as string | undefined + const segment = (item.data ?? {}) as Record + + for (const [region, revenueValue] of Object.entries(segment)) { + if (revenueValue != null) { + const revenue = Number(revenueValue) + if (!isNaN(revenue)) { + results.push( + FMPRevenueGeographicDataSchema.parse({ + period_ending: periodEnding, + fiscal_year: fiscalYear, + fiscal_period: fiscalPeriod, + region: region.replace('Segment', '').trim(), + revenue, + }), + ) + } + } + } + } + + if (results.length === 0) { + throw new EmptyDataError('Unknown error while transforming the data.') + } + + return results.sort((a, b) => { + const dateComp = String(a.period_ending ?? '').localeCompare(String(b.period_ending ?? '')) + if (dateComp !== 0) return dateComp + return (a.revenue ?? 0) - (b.revenue ?? 0) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/risk-premium.ts b/packages/opentypebb/src/providers/fmp/models/risk-premium.ts new file mode 100644 index 00000000..855dbf74 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/risk-premium.ts @@ -0,0 +1,38 @@ +/** + * FMP Risk Premium Model. + * Maps to: openbb_fmp/models/risk_premium.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { RiskPremiumQueryParamsSchema, RiskPremiumDataSchema } from '../../../standard-models/risk-premium.js' +import { getDataMany } from '../utils/helpers.js' + +export const FMPRiskPremiumQueryParamsSchema = RiskPremiumQueryParamsSchema +export type FMPRiskPremiumQueryParams = z.infer + +export const FMPRiskPremiumDataSchema = RiskPremiumDataSchema +export type FMPRiskPremiumData = z.infer + +export class FMPRiskPremiumFetcher extends Fetcher { + static override transformQuery(params: Record): FMPRiskPremiumQueryParams { + return FMPRiskPremiumQueryParamsSchema.parse(params) + } + + static override async extractData( + _query: FMPRiskPremiumQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + return getDataMany( + `https://financialmodelingprep.com/stable/market-risk-premium?apikey=${apiKey}`, + ) + } + + static override transformData( + _query: FMPRiskPremiumQueryParams, + data: Record[], + ): FMPRiskPremiumData[] { + return data.map(d => FMPRiskPremiumDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/share-statistics.ts b/packages/opentypebb/src/providers/fmp/models/share-statistics.ts new file mode 100644 index 00000000..89f9d313 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/share-statistics.ts @@ -0,0 +1,55 @@ +/** + * FMP Share Statistics Model. + * Maps to: openbb_fmp/models/share_statistics.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { ShareStatisticsQueryParamsSchema, ShareStatisticsDataSchema } from '../../../standard-models/share-statistics.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataMany } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + url: 'source', +} + +export const FMPShareStatisticsQueryParamsSchema = ShareStatisticsQueryParamsSchema +export type FMPShareStatisticsQueryParams = z.infer + +export const FMPShareStatisticsDataSchema = ShareStatisticsDataSchema.extend({ + url: z.string().nullable().default(null).describe('URL to the source filing.'), +}).passthrough() +export type FMPShareStatisticsData = z.infer + +export class FMPShareStatisticsFetcher extends Fetcher { + static override transformQuery(params: Record): FMPShareStatisticsQueryParams { + return FMPShareStatisticsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPShareStatisticsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + return getDataMany( + `https://financialmodelingprep.com/stable/shares-float?symbol=${query.symbol}&apikey=${apiKey}`, + ) + } + + static override transformData( + _query: FMPShareStatisticsQueryParams, + data: Record[], + ): FMPShareStatisticsData[] { + return data.map(d => { + // Normalize free_float from percent to decimal + if (typeof d.freeFloat === 'number') { + d.freeFloat = d.freeFloat / 100 + } + if (typeof d.free_float === 'number') { + d.free_float = d.free_float / 100 + } + const aliased = applyAliases(d, ALIAS_DICT) + return FMPShareStatisticsDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/treasury-rates.ts b/packages/opentypebb/src/providers/fmp/models/treasury-rates.ts new file mode 100644 index 00000000..8b3d1498 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/treasury-rates.ts @@ -0,0 +1,105 @@ +/** + * FMP Treasury Rates Model. + * Maps to: openbb_fmp/models/treasury_rates.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { TreasuryRatesQueryParamsSchema, TreasuryRatesDataSchema } from '../../../standard-models/treasury-rates.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getDataUrls } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + month_1: 'month1', + month_2: 'month2', + month_3: 'month3', + month_6: 'month6', + year_1: 'year1', + year_2: 'year2', + year_3: 'year3', + year_5: 'year5', + year_7: 'year7', + year_10: 'year10', + year_20: 'year20', + year_30: 'year30', +} + +export const FMPTreasuryRatesQueryParamsSchema = TreasuryRatesQueryParamsSchema +export type FMPTreasuryRatesQueryParams = z.infer + +export const FMPTreasuryRatesDataSchema = TreasuryRatesDataSchema +export type FMPTreasuryRatesData = z.infer + +/** + * Generate URLs for each 3-month interval between start and end dates. + */ +function generateUrls(startDate: string, endDate: string, apiKey: string): string[] { + const urls: string[] = [] + const start = new Date(startDate) + const end = new Date(endDate) + + let current = new Date(start) + while (current <= end) { + const next = new Date(current) + next.setMonth(next.getMonth() + 3) + const to = next > end ? end : next + const fromStr = current.toISOString().split('T')[0] + const toStr = to.toISOString().split('T')[0] + urls.push( + `https://financialmodelingprep.com/stable/treasury-rates?from=${fromStr}&to=${toStr}&apikey=${apiKey}`, + ) + current = next + } + return urls +} + +export class FMPTreasuryRatesFetcher extends Fetcher { + static override transformQuery(params: Record): FMPTreasuryRatesQueryParams { + // Default start_date to 1 year ago, end_date to today + const now = new Date() + if (!params.start_date) { + const oneYearAgo = new Date(now) + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) + params.start_date = oneYearAgo.toISOString().split('T')[0] + } + if (!params.end_date) { + params.end_date = now.toISOString().split('T')[0] + } + return FMPTreasuryRatesQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPTreasuryRatesQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const urls = generateUrls(query.start_date!, query.end_date!, apiKey) + const chunks = await getDataUrls[]>(urls) + // Flatten all chunks into a single array + const results: Record[] = [] + for (const chunk of chunks) { + if (Array.isArray(chunk)) { + results.push(...chunk) + } + } + return results + } + + static override transformData( + _query: FMPTreasuryRatesQueryParams, + data: Record[], + ): FMPTreasuryRatesData[] { + return data + .map(d => { + // Normalize percent values (e.g. 4.5 -> 0.045) + for (const [key, value] of Object.entries(d)) { + if (key !== 'date' && typeof value === 'number') { + d[key] = value / 100 + } + } + const aliased = applyAliases(d, ALIAS_DICT) + return FMPTreasuryRatesDataSchema.parse(aliased) + }) + .sort((a, b) => String(a.date).localeCompare(String(b.date))) + } +} diff --git a/packages/opentypebb/src/providers/fmp/models/world-news.ts b/packages/opentypebb/src/providers/fmp/models/world-news.ts new file mode 100644 index 00000000..91039ae5 --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/models/world-news.ts @@ -0,0 +1,80 @@ +/** + * FMP World News Model. + * Maps to: openbb_fmp/models/world_news.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { WorldNewsQueryParamsSchema, WorldNewsDataSchema } from '../../../standard-models/world-news.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getDataMany } from '../utils/helpers.js' + +// --- Query Params --- + +export const FMPWorldNewsQueryParamsSchema = WorldNewsQueryParamsSchema.extend({ + topic: z.enum(['fmp_articles', 'general', 'press_releases', 'stocks', 'forex', 'crypto']).default('general').describe('The topic of the news to be fetched.'), + page: z.number().int().min(0).max(100).nullable().default(null).describe('Page number of the results.'), +}) + +export type FMPWorldNewsQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + date: 'publishedDate', + images: 'image', + excerpt: 'text', + source: 'site', + author: 'publisher', + symbols: 'symbol', +} + +export const FMPWorldNewsDataSchema = WorldNewsDataSchema.extend({ + source: z.string().describe('News source.'), +}).passthrough() + +export type FMPWorldNewsData = z.infer + +// --- Fetcher --- + +export class FMPWorldNewsFetcher extends Fetcher { + static override transformQuery(params: Record): FMPWorldNewsQueryParams { + return FMPWorldNewsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: FMPWorldNewsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + const baseUrl = 'https://financialmodelingprep.com/stable/' + let url: string + + if (query.topic === 'fmp_articles') { + url = `${baseUrl}news/fmp-articles?page=${query.page ?? 0}&limit=${query.limit ?? 20}&apikey=${apiKey}` + } else { + const topicPath = query.topic.replace(/_/g, '-') + url = `${baseUrl}news/${topicPath}-latest?from=${query.start_date ?? ''}&to=${query.end_date ?? ''}&limit=${query.limit ?? 250}&page=${query.page ?? 0}&apikey=${apiKey}` + } + + const results = await getDataMany(url) + + return results.sort((a, b) => + String(b.publishedDate ?? '').localeCompare(String(a.publishedDate ?? '')), + ) + } + + static override transformData( + query: FMPWorldNewsQueryParams, + data: Record[], + ): FMPWorldNewsData[] { + if (!data || data.length === 0) { + throw new EmptyDataError('No data was returned from FMP query.') + } + return data.map((d) => { + const aliased = applyAliases(d, ALIAS_DICT) + return FMPWorldNewsDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/fmp/utils/definitions.ts b/packages/opentypebb/src/providers/fmp/utils/definitions.ts new file mode 100644 index 00000000..af96c52e --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/utils/definitions.ts @@ -0,0 +1,35 @@ +/** + * FMP Literal Definitions. + * Maps to: openbb_fmp/utils/definitions.py + */ + +export type FinancialPeriods = 'q1' | 'q2' | 'q3' | 'q4' | 'fy' | 'annual' | 'quarter' + +export type FinancialStatementPeriods = 'q1' | 'q2' | 'q3' | 'q4' | 'fy' | 'ttm' | 'annual' | 'quarter' + +export type TransactionType = + | 'award' | 'conversion' | 'return' | 'expire_short' | 'in_kind' + | 'gift' | 'expire_long' | 'discretionary' | 'other' | 'small' + | 'exempt' | 'otm' | 'purchase' | 'sale' | 'tender' | 'will' + | 'itm' | 'trust' + +export const TRANSACTION_TYPES_DICT: Record = { + award: 'A-Award', + conversion: 'C-Conversion', + return: 'D-Return', + expire_short: 'E-ExpireShort', + in_kind: 'F-InKind', + gift: 'G-Gift', + expire_long: 'H-ExpireLong', + discretionary: 'I-Discretionary', + other: 'J-Other', + small: 'L-Small', + exempt: 'M-Exempt', + otm: 'O-OutOfTheMoney', + purchase: 'P-Purchase', + sale: 'S-Sale', + tender: 'U-Tender', + will: 'W-Will', + itm: 'X-InTheMoney', + trust: 'Z-Trust', +} diff --git a/packages/opentypebb/src/providers/fmp/utils/helpers.ts b/packages/opentypebb/src/providers/fmp/utils/helpers.ts new file mode 100644 index 00000000..87b332fa --- /dev/null +++ b/packages/opentypebb/src/providers/fmp/utils/helpers.ts @@ -0,0 +1,291 @@ +/** + * FMP Helpers Module. + * Maps to: openbb_fmp/utils/helpers.py + */ + +import { OpenBBError, EmptyDataError, UnauthorizedError } from '../../../core/provider/utils/errors.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' + +/** + * Response callback for FMP API requests. + * Maps to: response_callback() in helpers.py + */ +export async function responseCallback(response: Response): Promise { + if (response.status !== 200) { + const msg = await response.text().catch(() => '') + throw new UnauthorizedError(`Unauthorized FMP request -> ${response.status} -> ${msg}`) + } + + const data = await response.json() + + if (data && typeof data === 'object' && !Array.isArray(data)) { + const errorMessage = (data as Record)['Error Message'] ?? + (data as Record)['error'] + + if (errorMessage != null) { + const msg = String(errorMessage).toLowerCase() + const isUnauthorized = + msg.includes('upgrade') || + msg.includes('exclusive endpoint') || + msg.includes('special endpoint') || + msg.includes('premium query parameter') || + msg.includes('subscription') || + msg.includes('unauthorized') || + msg.includes('premium') + + if (isUnauthorized) { + throw new UnauthorizedError(`Unauthorized FMP request -> ${errorMessage}`) + } + + throw new OpenBBError( + `FMP Error Message -> Status code: ${response.status} -> ${errorMessage}`, + ) + } + } + + // Return a new Response with the already-parsed body + return new Response(JSON.stringify(data), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) +} + +/** + * Get data from FMP endpoint. + * Maps to: get_data() in helpers.py + */ +export async function getData(url: string): Promise { + return amakeRequest(url, { responseCallback }) +} + +/** + * Get data from FMP for several urls. + * Maps to: get_data_urls() in helpers.py + */ +export async function getDataUrls(urls: string[]): Promise { + const results = await Promise.all( + urls.map((url) => amakeRequest(url, { responseCallback })), + ) + return results +} + +/** + * Get data from FMP endpoint and convert to list of dicts. + * Maps to: get_data_many() in helpers.py + */ +export async function getDataMany(url: string, subDict?: string): Promise[]> { + let data = await getData(url) + + if (subDict && data && typeof data === 'object' && !Array.isArray(data)) { + data = (data as Record)[subDict] ?? [] + } + + if (data && typeof data === 'object' && !Array.isArray(data)) { + throw new OpenBBError('Expected list of dicts, got dict') + } + + const arr = data as Record[] + if (!arr || arr.length === 0) { + throw new EmptyDataError() + } + + return arr +} + +/** + * Get data from FMP endpoint and convert to a single dict. + * Maps to: get_data_one() in helpers.py + */ +export async function getDataOne(url: string): Promise> { + let data = await getData(url) + + if (Array.isArray(data)) { + if (data.length === 0) { + throw new OpenBBError('Expected dict, got empty list') + } + data = data.length > 1 + ? Object.fromEntries(data.map((item, i) => [i, item])) + : data[0] + } + + return data as Record +} + +/** + * Create a URL for the FMP API. + * Maps to: create_url() in helpers.py + */ +export function createUrl( + version: number, + endpoint: string, + apiKey: string | null, + query?: Record, + exclude?: string[], +): string { + const params: Record = { ...(query ?? {}) } + const excludeSet = new Set(exclude ?? []) + + const searchParams = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (!excludeSet.has(key) && value !== null && value !== undefined) { + searchParams.set(key, String(value)) + } + } + + const queryString = searchParams.toString() + const baseUrl = `https://financialmodelingprep.com/api/v${version}/` + return `${baseUrl}${endpoint}?${queryString}&apikey=${apiKey ?? ''}` +} + +/** + * Get the FMP interval string. + * Maps to: get_interval() in helpers.py + */ +export function getInterval(value: string): string { + const intervals: Record = { + m: 'min', + h: 'hour', + d: 'day', + } + const suffix = value.slice(-1) + const num = value.slice(0, -1) + return `${num}${intervals[suffix] ?? suffix}` +} + +/** + * Get the most recent quarter date. + * Maps to: most_recent_quarter() in helpers.py + */ +export function mostRecentQuarter(base?: Date): Date { + const now = new Date() + let d = base ? new Date(Math.min(base.getTime(), now.getTime())) : new Date(now) + + const month = d.getMonth() + 1 // 1-indexed + const day = d.getDate() + + // Check exact quarter end dates + const exacts: [number, number][] = [[3, 31], [6, 30], [9, 30], [12, 31]] + for (const [m, dd] of exacts) { + if (month === m && day === dd) return d + } + + if (month < 4) return new Date(d.getFullYear() - 1, 11, 31) + if (month < 7) return new Date(d.getFullYear(), 2, 31) + if (month < 10) return new Date(d.getFullYear(), 5, 30) + return new Date(d.getFullYear(), 8, 30) +} + +/** + * Build query string from params, excluding specified keys. + * Maps to: get_querystring() in helpers.py + */ +export function getQueryString( + params: Record, + exclude: string[] = [], +): string { + const excludeSet = new Set(exclude) + const searchParams = new URLSearchParams() + + for (const [key, value] of Object.entries(params)) { + if (!excludeSet.has(key) && value !== null && value !== undefined) { + searchParams.set(key, String(value)) + } + } + + return searchParams.toString() +} + +/** + * Return the raw data from the FMP historical OHLC endpoint. + * Maps to: get_historical_ohlc() in helpers.py + */ +export async function getHistoricalOhlc( + query: { + symbol: string + interval: string + start_date?: string | null + end_date?: string | null + adjustment?: string + [key: string]: unknown + }, + credentials: Record | null, +): Promise[]> { + const apiKey = credentials?.fmp_api_key ?? '' + let baseUrl = 'https://financialmodelingprep.com/stable/' + + if (query.adjustment === 'unadjusted') { + baseUrl += 'historical-price-eod/non-split-adjusted?' + } else if (query.adjustment === 'splits_and_dividends') { + baseUrl += 'historical-price-eod/dividend-adjusted?' + } else if (query.interval === '1d') { + baseUrl += 'historical-price-eod/full?' + } else if (query.interval === '1m') { + baseUrl += 'historical-chart/1min?' + } else if (query.interval === '5m') { + baseUrl += 'historical-chart/5min?' + } else if (query.interval === '60m' || query.interval === '1h') { + baseUrl += 'historical-chart/1hour?' + } + + const queryParams = { ...query } + const excludeKeys = ['symbol', 'adjustment', 'interval'] + const queryStr = getQueryString(queryParams, excludeKeys) + const symbols = query.symbol.split(',') + + const results: Record[] = [] + const messages: string[] = [] + + const getOne = async (symbol: string) => { + const url = `${baseUrl}symbol=${symbol}&${queryStr}&apikey=${apiKey}` + + try { + const response = await amakeRequest(url, { responseCallback }) + + if (typeof response === 'object' && response !== null && !Array.isArray(response)) { + const dict = response as Record + if (dict['Error Message']) { + const message = `Error fetching data for ${symbol}: ${dict['Error Message']}` + console.warn(message) + messages.push(message) + return + } + + const historical = dict['historical'] as Record[] | undefined + if (historical && historical.length > 0) { + for (const d of historical) { + d.symbol = symbol + results.push(d) + } + return + } + } + + if (Array.isArray(response) && response.length > 0) { + for (const d of response as Record[]) { + d.symbol = symbol + results.push(d) + } + return + } + + const message = `No data found for ${symbol}.` + console.warn(message) + messages.push(message) + } catch (error) { + const message = `Error fetching data for ${symbol}: ${error}` + console.warn(message) + messages.push(message) + } + } + + await Promise.all(symbols.map(getOne)) + + if (results.length === 0) { + throw new EmptyDataError( + messages.length > 0 ? messages.join(' ') : 'No data found', + ) + } + + return results +} diff --git a/packages/opentypebb/src/providers/imf/index.ts b/packages/opentypebb/src/providers/imf/index.ts new file mode 100644 index 00000000..dc1af271 --- /dev/null +++ b/packages/opentypebb/src/providers/imf/index.ts @@ -0,0 +1,22 @@ +/** + * IMF Provider Module. + * Maps to: openbb_platform/providers/imf/openbb_imf/__init__.py + */ + +import { Provider } from '../../core/provider/abstract/provider.js' +import { IMFAvailableIndicatorsFetcher } from './models/available-indicators.js' +import { IMFConsumerPriceIndexFetcher } from './models/consumer-price-index.js' +import { IMFDirectionOfTradeFetcher } from './models/direction-of-trade.js' +import { IMFEconomicIndicatorsFetcher } from './models/economic-indicators.js' + +export const imfProvider = new Provider({ + name: 'imf', + website: 'https://data.imf.org', + description: 'International Monetary Fund data services.', + fetcherDict: { + AvailableIndicators: IMFAvailableIndicatorsFetcher, + ConsumerPriceIndex: IMFConsumerPriceIndexFetcher, + DirectionOfTrade: IMFDirectionOfTradeFetcher, + EconomicIndicators: IMFEconomicIndicatorsFetcher, + }, +}) diff --git a/packages/opentypebb/src/providers/imf/models/available-indicators.ts b/packages/opentypebb/src/providers/imf/models/available-indicators.ts new file mode 100644 index 00000000..85cdf12c --- /dev/null +++ b/packages/opentypebb/src/providers/imf/models/available-indicators.ts @@ -0,0 +1,58 @@ +/** + * IMF Available Indicators Model. + * Maps to: openbb_imf/models/available_indicators.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { AvailableIndicatorsDataSchema } from '../../../standard-models/available-indicators.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { nativeFetch } from '../../../core/provider/utils/helpers.js' + +export const IMFAvailableIndicatorsQueryParamsSchema = z.object({}).passthrough() +export type IMFAvailableIndicatorsQueryParams = z.infer +export type IMFAvailableIndicatorsData = z.infer + +export class IMFAvailableIndicatorsFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): IMFAvailableIndicatorsQueryParams { + return IMFAvailableIndicatorsQueryParamsSchema.parse(params) + } + + static override async extractData( + _query: IMFAvailableIndicatorsQueryParams, + _credentials: Record | null, + ): Promise[]> { + // IMF Dataflow endpoint lists available datasets + const url = 'https://dataservices.imf.org/REST/SDMX_JSON.svc/Dataflow' + + try { + const resp = await nativeFetch(url, { timeoutMs: 30000 }) + if (resp.status !== 200) throw new EmptyDataError(`IMF API returned ${resp.status}`) + const data = JSON.parse(resp.text) as Record + const structure = data.Structure as Record + const dataflows = (structure?.Dataflows as Record)?.Dataflow as Record[] + + if (!Array.isArray(dataflows) || dataflows.length === 0) throw new EmptyDataError() + + return dataflows.map(df => ({ + symbol: (df.KeyFamilyRef as Record)?.KeyFamilyID ?? df['@id'] ?? '', + description: ((df.Name as Record)?.['#text'] ?? df.Name ?? '') as string, + })) + } catch (err) { + if (err instanceof EmptyDataError) throw err + throw new EmptyDataError(`Failed to fetch IMF indicators: ${err}`) + } + } + + static override transformData( + _query: IMFAvailableIndicatorsQueryParams, + data: Record[], + ): IMFAvailableIndicatorsData[] { + return data.map(d => AvailableIndicatorsDataSchema.parse({ + symbol: d.symbol ?? null, + description: d.description ?? null, + })) + } +} diff --git a/packages/opentypebb/src/providers/imf/models/consumer-price-index.ts b/packages/opentypebb/src/providers/imf/models/consumer-price-index.ts new file mode 100644 index 00000000..1a134909 --- /dev/null +++ b/packages/opentypebb/src/providers/imf/models/consumer-price-index.ts @@ -0,0 +1,95 @@ +/** + * IMF Consumer Price Index Model. + * Maps to: openbb_imf/models/consumer_price_index.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { ConsumerPriceIndexDataSchema } from '../../../standard-models/consumer-price-index.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { nativeFetch } from '../../../core/provider/utils/helpers.js' + +export const IMFCPIQueryParamsSchema = z.object({ + country: z.string().default('united_states').describe('The country to get data for.'), + transform: z.string().default('yoy').describe('Transformation: yoy, period, index.'), + frequency: z.enum(['annual', 'quarter', 'monthly']).default('monthly').describe('Data frequency.'), + harmonized: z.boolean().default(false).describe('If true, returns harmonized data.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type IMFCPIQueryParams = z.infer +export type IMFCPIData = z.infer + +const COUNTRY_ISO2: Record = { + united_states: 'US', united_kingdom: 'GB', japan: 'JP', germany: 'DE', + france: 'FR', italy: 'IT', canada: 'CA', australia: 'AU', + china: 'CN', india: 'IN', brazil: 'BR', mexico: 'MX', +} + +const FREQ_MAP: Record = { annual: 'A', quarter: 'Q', monthly: 'M' } + +export class IMFConsumerPriceIndexFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): IMFCPIQueryParams { + return IMFCPIQueryParamsSchema.parse(params) + } + + static override async extractData( + query: IMFCPIQueryParams, + _credentials: Record | null, + ): Promise[]> { + const iso = COUNTRY_ISO2[query.country] ?? query.country.toUpperCase().slice(0, 2) + const freq = FREQ_MAP[query.frequency] ?? 'M' + + // IMF CPI data from the CPI database + const indicator = query.transform === 'yoy' ? 'PCPIEPCH' : 'PCPIPCH' + const url = `https://dataservices.imf.org/REST/SDMX_JSON.svc/CompactData/CPI/${freq}.${iso}.${indicator}` + + try { + const resp = await nativeFetch(url, { timeoutMs: 30000 }) + if (resp.status !== 200) throw new EmptyDataError(`IMF API returned ${resp.status}`) + const data = JSON.parse(resp.text) as Record + const dataset = data.CompactData as Record + const dataSet = dataset?.DataSet as Record + let series = dataSet?.Series as Record | Record[] + + if (!series) throw new EmptyDataError() + if (!Array.isArray(series)) series = [series] + + const results: Record[] = [] + for (const s of series) { + let obs = (s.Obs ?? []) as Record | Record[] + if (!Array.isArray(obs)) obs = [obs] + + for (const o of obs) { + const period = o['@TIME_PERIOD'] as string + const value = parseFloat(o['@OBS_VALUE'] as string) + if (period && !isNaN(value)) { + const date = period.length === 7 ? period + '-01' : period.length === 4 ? period + '-01-01' : period + results.push({ date, country: query.country, value }) + } + } + } + + return results + } catch (err) { + if (err instanceof EmptyDataError) throw err + throw new EmptyDataError(`Failed to fetch IMF CPI data: ${err}`) + } + } + + static override transformData( + query: IMFCPIQueryParams, + data: Record[], + ): IMFCPIData[] { + if (data.length === 0) throw new EmptyDataError() + let filtered = data + if (query.start_date) filtered = filtered.filter(d => String(d.date) >= query.start_date!) + if (query.end_date) filtered = filtered.filter(d => String(d.date) <= query.end_date!) + return filtered + .sort((a, b) => String(a.date).localeCompare(String(b.date))) + .map(d => ConsumerPriceIndexDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/imf/models/direction-of-trade.ts b/packages/opentypebb/src/providers/imf/models/direction-of-trade.ts new file mode 100644 index 00000000..d40979f3 --- /dev/null +++ b/packages/opentypebb/src/providers/imf/models/direction-of-trade.ts @@ -0,0 +1,106 @@ +/** + * IMF Direction of Trade Model. + * Maps to: openbb_imf/models/direction_of_trade.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { DirectionOfTradeDataSchema } from '../../../standard-models/direction-of-trade.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { nativeFetch } from '../../../core/provider/utils/helpers.js' + +export const IMFDOTQueryParamsSchema = z.object({ + country: z.string().nullable().default(null).describe('Country for the trade data.'), + counterpart: z.string().nullable().default(null).describe('Counterpart country.'), + direction: z.enum(['exports', 'imports', 'balance', 'all']).default('balance').describe('Trade direction.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), + frequency: z.enum(['month', 'quarter', 'annual']).default('month').describe('Data frequency.'), +}).passthrough() + +export type IMFDOTQueryParams = z.infer +export type IMFDOTData = z.infer + +const COUNTRY_ISO2: Record = { + united_states: 'US', united_kingdom: 'GB', japan: 'JP', germany: 'DE', + france: 'FR', china: 'CN', india: 'IN', brazil: 'BR', +} + +const FREQ_MAP: Record = { month: 'M', quarter: 'Q', annual: 'A' } +const DIRECTION_MAP: Record = { exports: 'TXG_FOB_USD', imports: 'TMG_CIF_USD', balance: 'TBG_USD' } + +export class IMFDirectionOfTradeFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): IMFDOTQueryParams { + return IMFDOTQueryParamsSchema.parse(params) + } + + static override async extractData( + query: IMFDOTQueryParams, + _credentials: Record | null, + ): Promise[]> { + const country = query.country ? (COUNTRY_ISO2[query.country] ?? query.country.toUpperCase().slice(0, 2)) : '' + const counterpart = query.counterpart ? (COUNTRY_ISO2[query.counterpart] ?? query.counterpart.toUpperCase().slice(0, 2)) : 'W00' + const freq = FREQ_MAP[query.frequency] ?? 'M' + const indicators = query.direction === 'all' + ? 'TXG_FOB_USD+TMG_CIF_USD+TBG_USD' + : DIRECTION_MAP[query.direction] ?? 'TBG_USD' + + const url = `https://dataservices.imf.org/REST/SDMX_JSON.svc/CompactData/DOT/${freq}.${country}.${indicators}.${counterpart}` + + try { + const resp = await nativeFetch(url, { timeoutMs: 30000 }) + if (resp.status !== 200) throw new EmptyDataError(`IMF API returned ${resp.status}`) + const data = JSON.parse(resp.text) as Record + const dataset = data.CompactData as Record + const dataSet = dataset?.DataSet as Record + let series = dataSet?.Series as Record | Record[] + + if (!series) throw new EmptyDataError() + if (!Array.isArray(series)) series = [series] + + const results: Record[] = [] + for (const s of series) { + const indicator = s['@INDICATOR'] as string + let obs = (s.Obs ?? []) as Record | Record[] + if (!Array.isArray(obs)) obs = [obs] + + for (const o of obs) { + const period = o['@TIME_PERIOD'] as string + const value = parseFloat(o['@OBS_VALUE'] as string) + if (period && !isNaN(value)) { + const date = period.length === 7 ? period + '-01' : period.length === 4 ? period + '-01-01' : period + results.push({ + date, + symbol: indicator, + country: query.country ?? 'all', + counterpart: query.counterpart ?? 'world', + title: indicator, + value, + scale: 'millions_usd', + }) + } + } + } + + return results + } catch (err) { + if (err instanceof EmptyDataError) throw err + throw new EmptyDataError(`Failed to fetch IMF DOT data: ${err}`) + } + } + + static override transformData( + query: IMFDOTQueryParams, + data: Record[], + ): IMFDOTData[] { + if (data.length === 0) throw new EmptyDataError() + let filtered = data + if (query.start_date) filtered = filtered.filter(d => String(d.date) >= query.start_date!) + if (query.end_date) filtered = filtered.filter(d => String(d.date) <= query.end_date!) + return filtered + .sort((a, b) => String(a.date).localeCompare(String(b.date))) + .map(d => DirectionOfTradeDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/imf/models/economic-indicators.ts b/packages/opentypebb/src/providers/imf/models/economic-indicators.ts new file mode 100644 index 00000000..27571e09 --- /dev/null +++ b/packages/opentypebb/src/providers/imf/models/economic-indicators.ts @@ -0,0 +1,90 @@ +/** + * IMF Economic Indicators Model. + * Maps to: openbb_imf/models/economic_indicators.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EconomicIndicatorsDataSchema } from '../../../standard-models/economic-indicators.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { nativeFetch } from '../../../core/provider/utils/helpers.js' + +export const IMFEconomicIndicatorsQueryParamsSchema = z.object({ + symbol: z.string().describe('IMF dataset/indicator code (e.g., IFS, BOP, GFSR).'), + country: z.string().nullable().default(null).describe('Country ISO2 code.'), + frequency: z.string().nullable().default(null).describe('Data frequency (A, Q, M).'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type IMFEconomicIndicatorsQueryParams = z.infer +export type IMFEconomicIndicatorsData = z.infer + +export class IMFEconomicIndicatorsFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): IMFEconomicIndicatorsQueryParams { + return IMFEconomicIndicatorsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: IMFEconomicIndicatorsQueryParams, + _credentials: Record | null, + ): Promise[]> { + const freq = query.frequency ?? 'A' + const country = query.country ?? 'US' + const url = `https://dataservices.imf.org/REST/SDMX_JSON.svc/CompactData/${query.symbol}/${freq}.${country}` + + try { + const resp = await nativeFetch(url, { timeoutMs: 30000 }) + if (resp.status !== 200) throw new EmptyDataError(`IMF API returned ${resp.status}`) + const data = JSON.parse(resp.text) as Record + const dataset = data.CompactData as Record + const dataSet = dataset?.DataSet as Record + let series = dataSet?.Series as Record | Record[] + + if (!series) throw new EmptyDataError() + if (!Array.isArray(series)) series = [series] + + const results: Record[] = [] + for (const s of series) { + const indicator = s['@INDICATOR'] as string ?? query.symbol + let obs = (s.Obs ?? []) as Record | Record[] + if (!Array.isArray(obs)) obs = [obs] + + for (const o of obs) { + const period = o['@TIME_PERIOD'] as string + const value = parseFloat(o['@OBS_VALUE'] as string) + if (period && !isNaN(value)) { + const date = period.length === 7 ? period + '-01' : period.length === 4 ? period + '-01-01' : period + results.push({ + date, + symbol_root: query.symbol, + symbol: `${query.symbol}.${indicator}`, + country: query.country, + value, + }) + } + } + } + + return results + } catch (err) { + if (err instanceof EmptyDataError) throw err + throw new EmptyDataError(`Failed to fetch IMF data: ${err}`) + } + } + + static override transformData( + query: IMFEconomicIndicatorsQueryParams, + data: Record[], + ): IMFEconomicIndicatorsData[] { + if (data.length === 0) throw new EmptyDataError() + let filtered = data + if (query.start_date) filtered = filtered.filter(d => String(d.date) >= query.start_date!) + if (query.end_date) filtered = filtered.filter(d => String(d.date) <= query.end_date!) + return filtered + .sort((a, b) => String(a.date).localeCompare(String(b.date))) + .map(d => EconomicIndicatorsDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/intrinio/index.ts b/packages/opentypebb/src/providers/intrinio/index.ts new file mode 100644 index 00000000..f7abd4e8 --- /dev/null +++ b/packages/opentypebb/src/providers/intrinio/index.ts @@ -0,0 +1,19 @@ +/** + * Intrinio Provider Module. + * Maps to: openbb_platform/providers/intrinio/openbb_intrinio/__init__.py + */ + +import { Provider } from '../../core/provider/abstract/provider.js' +import { IntrinioOptionsSnapshotsFetcher } from './models/options-snapshots.js' +import { IntrinioOptionsUnusualFetcher } from './models/options-unusual.js' + +export const intrinioProvider = new Provider({ + name: 'intrinio', + website: 'https://intrinio.com', + description: 'Intrinio provides financial data and analytics APIs.', + credentials: ['api_key'], + fetcherDict: { + OptionsSnapshots: IntrinioOptionsSnapshotsFetcher, + OptionsUnusual: IntrinioOptionsUnusualFetcher, + }, +}) diff --git a/packages/opentypebb/src/providers/intrinio/models/options-snapshots.ts b/packages/opentypebb/src/providers/intrinio/models/options-snapshots.ts new file mode 100644 index 00000000..53fe372d --- /dev/null +++ b/packages/opentypebb/src/providers/intrinio/models/options-snapshots.ts @@ -0,0 +1,65 @@ +/** + * Intrinio Options Snapshots Model. + * Maps to: openbb_intrinio/models/options_snapshots.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { OptionsSnapshotsDataSchema } from '../../../standard-models/options-snapshots.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' + +export const IntrinioOptionsSnapshotsQueryParamsSchema = z.object({}).passthrough() +export type IntrinioOptionsSnapshotsQueryParams = z.infer +export type IntrinioOptionsSnapshotsData = z.infer + +export class IntrinioOptionsSnapshotsFetcher extends Fetcher { + static override requireCredentials = true + + static override transformQuery(params: Record): IntrinioOptionsSnapshotsQueryParams { + return IntrinioOptionsSnapshotsQueryParamsSchema.parse(params) + } + + static override async extractData( + _query: IntrinioOptionsSnapshotsQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.intrinio_api_key ?? '' + if (!apiKey) throw new EmptyDataError('Intrinio API key required.') + + const url = `https://api-v2.intrinio.com/options/snapshots?api_key=${apiKey}` + + try { + const data = await amakeRequest>(url) + const snapshots = (data.snapshots ?? data.options ?? []) as Record[] + if (!Array.isArray(snapshots) || snapshots.length === 0) throw new EmptyDataError() + return snapshots + } catch (err) { + if (err instanceof EmptyDataError) throw err + throw new EmptyDataError(`Failed to fetch Intrinio options snapshots: ${err}`) + } + } + + static override transformData( + _query: IntrinioOptionsSnapshotsQueryParams, + data: Record[], + ): IntrinioOptionsSnapshotsData[] { + return data.map(d => OptionsSnapshotsDataSchema.parse({ + underlying_symbol: d.underlying_symbol ?? d.ticker ?? '', + contract_symbol: d.contract_symbol ?? d.code ?? '', + expiration: d.expiration ?? '', + dte: d.dte ?? null, + strike: d.strike ?? 0, + option_type: d.type ?? d.option_type ?? '', + volume: d.volume ?? null, + open_interest: d.open_interest ?? null, + last_price: d.last ?? d.last_price ?? null, + last_size: d.last_size ?? null, + last_timestamp: d.last_timestamp ?? null, + open: d.open ?? null, + high: d.high ?? null, + low: d.low ?? null, + close: d.close ?? null, + })) + } +} diff --git a/packages/opentypebb/src/providers/intrinio/models/options-unusual.ts b/packages/opentypebb/src/providers/intrinio/models/options-unusual.ts new file mode 100644 index 00000000..fe5099b1 --- /dev/null +++ b/packages/opentypebb/src/providers/intrinio/models/options-unusual.ts @@ -0,0 +1,56 @@ +/** + * Intrinio Options Unusual Model. + * Maps to: openbb_intrinio/models/options_unusual.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { OptionsUnusualDataSchema } from '../../../standard-models/options-unusual.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' + +export const IntrinioOptionsUnusualQueryParamsSchema = z.object({ + symbol: z.string().nullable().default(null).transform(v => v ? v.toUpperCase() : null).describe('Symbol to filter by.'), +}).passthrough() + +export type IntrinioOptionsUnusualQueryParams = z.infer +export type IntrinioOptionsUnusualData = z.infer + +export class IntrinioOptionsUnusualFetcher extends Fetcher { + static override requireCredentials = true + + static override transformQuery(params: Record): IntrinioOptionsUnusualQueryParams { + return IntrinioOptionsUnusualQueryParamsSchema.parse(params) + } + + static override async extractData( + query: IntrinioOptionsUnusualQueryParams, + credentials: Record | null, + ): Promise[]> { + const apiKey = credentials?.intrinio_api_key ?? '' + if (!apiKey) throw new EmptyDataError('Intrinio API key required.') + + let url = `https://api-v2.intrinio.com/options/unusual_activity?api_key=${apiKey}` + if (query.symbol) url += `&symbol=${query.symbol}` + + try { + const data = await amakeRequest>(url) + const activities = (data.trades ?? data.unusual_activity ?? []) as Record[] + if (!Array.isArray(activities) || activities.length === 0) throw new EmptyDataError() + return activities + } catch (err) { + if (err instanceof EmptyDataError) throw err + throw new EmptyDataError(`Failed to fetch Intrinio unusual options: ${err}`) + } + } + + static override transformData( + _query: IntrinioOptionsUnusualQueryParams, + data: Record[], + ): IntrinioOptionsUnusualData[] { + return data.map(d => OptionsUnusualDataSchema.parse({ + underlying_symbol: d.symbol ?? d.underlying_symbol ?? null, + contract_symbol: d.contract ?? d.contract_symbol ?? '', + })) + } +} diff --git a/packages/opentypebb/src/providers/multpl/index.ts b/packages/opentypebb/src/providers/multpl/index.ts new file mode 100644 index 00000000..be86705b --- /dev/null +++ b/packages/opentypebb/src/providers/multpl/index.ts @@ -0,0 +1,16 @@ +/** + * Multpl Provider Module. + * Maps to: openbb_platform/providers/multpl/openbb_multpl/__init__.py + */ + +import { Provider } from '../../core/provider/abstract/provider.js' +import { MultplSP500MultiplesFetcher } from './models/sp500-multiples.js' + +export const multplProvider = new Provider({ + name: 'multpl', + website: 'https://www.multpl.com/', + description: 'Public broad-market data published to https://multpl.com.', + fetcherDict: { + SP500Multiples: MultplSP500MultiplesFetcher, + }, +}) diff --git a/packages/opentypebb/src/providers/multpl/models/sp500-multiples.ts b/packages/opentypebb/src/providers/multpl/models/sp500-multiples.ts new file mode 100644 index 00000000..fc7f2e2a --- /dev/null +++ b/packages/opentypebb/src/providers/multpl/models/sp500-multiples.ts @@ -0,0 +1,183 @@ +/** + * Multpl S&P 500 Multiples Model. + * Maps to: openbb_multpl/models/sp500_multiples.py + * + * Scrapes data tables from multpl.com. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { SP500MultiplesDataSchema } from '../../../standard-models/sp500-multiples.js' +import { EmptyDataError, OpenBBError } from '../../../core/provider/utils/errors.js' +import { nativeFetch } from '../../../core/provider/utils/helpers.js' + +const BASE_URL = 'https://www.multpl.com/' + +const URL_DICT: Record = { + shiller_pe_month: 'shiller-pe/table/by-month', + shiller_pe_year: 'shiller-pe/table/by-year', + pe_year: 's-p-500-pe-ratio/table/by-year', + pe_month: 's-p-500-pe-ratio/table/by-month', + dividend_year: 's-p-500-dividend/table/by-year', + dividend_month: 's-p-500-dividend/table/by-month', + dividend_growth_quarter: 's-p-500-dividend-growth/table/by-quarter', + dividend_growth_year: 's-p-500-dividend-growth/table/by-year', + dividend_yield_year: 's-p-500-dividend-yield/table/by-year', + dividend_yield_month: 's-p-500-dividend-yield/table/by-month', + earnings_year: 's-p-500-earnings/table/by-year', + earnings_month: 's-p-500-earnings/table/by-month', + earnings_growth_year: 's-p-500-earnings-growth/table/by-year', + earnings_growth_quarter: 's-p-500-earnings-growth/table/by-quarter', + real_earnings_growth_year: 's-p-500-real-earnings-growth/table/by-year', + real_earnings_growth_quarter: 's-p-500-real-earnings-growth/table/by-quarter', + earnings_yield_year: 's-p-500-earnings-yield/table/by-year', + earnings_yield_month: 's-p-500-earnings-yield/table/by-month', + real_price_year: 's-p-500-historical-prices/table/by-year', + real_price_month: 's-p-500-historical-prices/table/by-month', + inflation_adjusted_price_year: 'inflation-adjusted-s-p-500/table/by-year', + inflation_adjusted_price_month: 'inflation-adjusted-s-p-500/table/by-month', + sales_year: 's-p-500-sales/table/by-year', + sales_quarter: 's-p-500-sales/table/by-quarter', + sales_growth_year: 's-p-500-sales-growth/table/by-year', + sales_growth_quarter: 's-p-500-sales-growth/table/by-quarter', + real_sales_year: 's-p-500-real-sales/table/by-year', + real_sales_quarter: 's-p-500-real-sales/table/by-quarter', + real_sales_growth_year: 's-p-500-real-sales-growth/table/by-year', + real_sales_growth_quarter: 's-p-500-real-sales-growth/table/by-quarter', + price_to_sales_year: 's-p-500-price-to-sales/table/by-year', + price_to_sales_quarter: 's-p-500-price-to-sales/table/by-quarter', + price_to_book_value_year: 's-p-500-price-to-book/table/by-year', + price_to_book_value_quarter: 's-p-500-price-to-book/table/by-quarter', + book_value_year: 's-p-500-book-value/table/by-year', + book_value_quarter: 's-p-500-book-value/table/by-quarter', +} + +export const MultplSP500MultiplesQueryParamsSchema = z.object({ + series_name: z.string().default('pe_month').describe('The name of the series.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type MultplSP500MultiplesQueryParams = z.infer + +export type MultplSP500MultiplesData = z.infer + +/** + * Parse an HTML table from multpl.com. + * The tables have format: Date | Value + */ +function parseHtmlTable(html: string): Array<{ date: string; value: string }> { + const rows: Array<{ date: string; value: string }> = [] + // Match table rows with two cells + const rowRegex = /]*>\s*]*>(.*?)<\/td>\s*]*>(.*?)<\/td>\s*<\/tr>/gs + let match: RegExpExecArray | null + while ((match = rowRegex.exec(html)) !== null) { + const dateStr = match[1].replace(/<[^>]+>/g, '').trim() + const valueStr = match[2].replace(/<[^>]+>/g, '').trim() + if (dateStr && valueStr && !dateStr.toLowerCase().includes('date')) { + rows.push({ date: dateStr, value: valueStr }) + } + } + return rows +} + +/** + * Parse a date string from multpl.com (e.g., "Jan 31, 2024"). + */ +function parseDate(dateStr: string): string | null { + try { + const d = new Date(dateStr) + if (isNaN(d.getTime())) return null + return d.toISOString().slice(0, 10) + } catch { + return null + } +} + +/** + * Parse a value string from multpl.com. + */ +function parseValue(valueStr: string, isPercent: boolean): number | null { + const cleaned = valueStr.replace(/†/g, '').replace(/%/g, '').replace(/\$/g, '').replace(/,/g, '').trim() + const num = parseFloat(cleaned) + if (isNaN(num)) return null + return isPercent ? num / 100 : num +} + +export class MultplSP500MultiplesFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): MultplSP500MultiplesQueryParams { + const query = MultplSP500MultiplesQueryParamsSchema.parse(params) + // Validate series names + const series = query.series_name.split(',') + for (const s of series) { + if (!URL_DICT[s]) { + throw new OpenBBError(`Invalid series_name: ${s}. Valid: ${Object.keys(URL_DICT).sort().join(', ')}`) + } + } + return query + } + + static override async extractData( + query: MultplSP500MultiplesQueryParams, + _credentials: Record | null, + ): Promise[]> { + const series = query.series_name.split(',') + const results: Record[] = [] + + const tasks = series.map(async (seriesName) => { + const path = URL_DICT[seriesName] + const url = `${BASE_URL}${path}` + + try { + const resp = await nativeFetch(url, { timeoutMs: 30000 }) + if (resp.status !== 200) { + console.warn(`Failed to fetch ${seriesName}: ${resp.status}`) + return + } + + const html = resp.text + const rows = parseHtmlTable(html) + const isPercent = seriesName.includes('growth') || seriesName.includes('yield') + + for (const row of rows) { + const date = parseDate(row.date) + if (!date) continue + + // Filter by date range + if (query.start_date && date < query.start_date) continue + if (query.end_date && date > query.end_date) continue + + const value = parseValue(row.value, isPercent) + if (value === null) continue + + results.push({ + date, + name: seriesName, + value, + }) + } + } catch (err) { + console.warn(`Failed to get data for ${seriesName}: ${err}`) + } + }) + + await Promise.all(tasks) + + if (results.length === 0) throw new EmptyDataError('No data found.') + return results + } + + static override transformData( + _query: MultplSP500MultiplesQueryParams, + data: Record[], + ): MultplSP500MultiplesData[] { + const sorted = data.sort((a, b) => { + const dateCompare = String(a.date).localeCompare(String(b.date)) + if (dateCompare !== 0) return dateCompare + return String(a.name).localeCompare(String(b.name)) + }) + return sorted.map(d => SP500MultiplesDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/oecd/index.ts b/packages/opentypebb/src/providers/oecd/index.ts new file mode 100644 index 00000000..a8499969 --- /dev/null +++ b/packages/opentypebb/src/providers/oecd/index.ts @@ -0,0 +1,32 @@ +/** + * OECD Provider Module. + * Maps to: openbb_platform/providers/oecd/openbb_oecd/__init__.py + */ + +import { Provider } from '../../core/provider/abstract/provider.js' +import { OECDCompositeLeadingIndicatorFetcher } from './models/composite-leading-indicator.js' +import { OECDConsumerPriceIndexFetcher } from './models/consumer-price-index.js' +import { OECDCountryInterestRatesFetcher } from './models/country-interest-rates.js' +import { OECDGdpForecastFetcher } from './models/gdp-forecast.js' +import { OECDGdpNominalFetcher } from './models/gdp-nominal.js' +import { OECDGdpRealFetcher } from './models/gdp-real.js' +import { OECDSharePriceIndexFetcher } from './models/share-price-index.js' +import { OECDHousePriceIndexFetcher } from './models/house-price-index.js' +import { OECDRetailPricesFetcher } from './models/retail-prices.js' + +export const oecdProvider = new Provider({ + name: 'oecd', + website: 'https://data.oecd.org', + description: 'OECD provides international economic, social, and environmental data.', + fetcherDict: { + CompositeLeadingIndicator: OECDCompositeLeadingIndicatorFetcher, + ConsumerPriceIndex: OECDConsumerPriceIndexFetcher, + CountryInterestRates: OECDCountryInterestRatesFetcher, + GdpForecast: OECDGdpForecastFetcher, + GdpNominal: OECDGdpNominalFetcher, + GdpReal: OECDGdpRealFetcher, + SharePriceIndex: OECDSharePriceIndexFetcher, + HousePriceIndex: OECDHousePriceIndexFetcher, + RetailPrices: OECDRetailPricesFetcher, + }, +}) diff --git a/packages/opentypebb/src/providers/oecd/models/composite-leading-indicator.ts b/packages/opentypebb/src/providers/oecd/models/composite-leading-indicator.ts new file mode 100644 index 00000000..00711a7c --- /dev/null +++ b/packages/opentypebb/src/providers/oecd/models/composite-leading-indicator.ts @@ -0,0 +1,109 @@ +/** + * OECD Composite Leading Indicator Model. + * Maps to: openbb_oecd/models/composite_leading_indicator.py + * + * Uses CSV format from OECD SDMX REST API (same as Python implementation). + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CompositeLeadingIndicatorDataSchema } from '../../../standard-models/composite-leading-indicator.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { nativeFetch } from '../../../core/provider/utils/helpers.js' + +export const OECDCLIQueryParamsSchema = z.object({ + country: z.string().default('g20').describe('Country or group code (g20, united_states, all, etc).'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type OECDCLIQueryParams = z.infer +export type OECDCLIData = z.infer + +const COUNTRIES: Record = { + g20: 'G20', g7: 'G7', asia5: 'A5M', north_america: 'NAFTA', europe4: 'G4E', + australia: 'AUS', brazil: 'BRA', canada: 'CAN', china: 'CHN', france: 'FRA', + germany: 'DEU', india: 'IND', indonesia: 'IDN', italy: 'ITA', japan: 'JPN', + mexico: 'MEX', spain: 'ESP', south_africa: 'ZAF', south_korea: 'KOR', + turkey: 'TUR', united_states: 'USA', united_kingdom: 'GBR', +} + +const CODE_TO_NAME: Record = Object.fromEntries( + Object.entries(COUNTRIES).map(([k, v]) => [v, k.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())]), +) + +/** Parse simple CSV text into rows */ +function parseCSV(text: string): Record[] { + const lines = text.trim().split('\n') + if (lines.length < 2) return [] + const headers = lines[0].split(',') + return lines.slice(1).map(line => { + const values = line.split(',') + const row: Record = {} + headers.forEach((h, i) => { row[h.trim()] = values[i]?.trim() ?? '' }) + return row + }) +} + +export class OECDCompositeLeadingIndicatorFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): OECDCLIQueryParams { + if (!params.start_date) params.start_date = '1947-01-01' + if (!params.end_date) { + const y = new Date().getFullYear() + params.end_date = `${y}-12-31` + } + return OECDCLIQueryParamsSchema.parse(params) + } + + static override async extractData( + query: OECDCLIQueryParams, + _credentials: Record | null, + ): Promise[]> { + // Build country code string + let countryCode = '' + if (query.country && query.country !== 'all') { + const parts = query.country.split(',') + countryCode = parts.map(c => COUNTRIES[c.toLowerCase().trim()] ?? c.toUpperCase()).join('+') + } + + const url = + `https://sdmx.oecd.org/public/rest/data/OECD.SDD.STES,DSD_STES@DF_CLI,4.1` + + `/${countryCode}.M.LI...AA.IX..H` + + `?startPeriod=${query.start_date}&endPeriod=${query.end_date}` + + `&dimensionAtObservation=TIME_PERIOD&detail=dataonly&format=csvfile` + + try { + const resp = await nativeFetch(url, { + headers: { Accept: 'application/vnd.sdmx.data+csv; charset=utf-8' }, + timeoutMs: 30000, + }) + if (resp.status !== 200) throw new EmptyDataError(`OECD API returned ${resp.status}`) + const text = resp.text + const rows = parseCSV(text) + if (!rows.length) throw new EmptyDataError() + + return rows + .filter(r => r.OBS_VALUE && r.OBS_VALUE !== '') + .map(r => ({ + date: r.TIME_PERIOD ? r.TIME_PERIOD + '-01' : '', + value: parseFloat(r.OBS_VALUE), + country: CODE_TO_NAME[r.REF_AREA] ?? r.REF_AREA ?? 'Unknown', + })) + } catch (err) { + if (err instanceof EmptyDataError) throw err + throw new EmptyDataError(`Failed to fetch OECD CLI data: ${err}`) + } + } + + static override transformData( + _query: OECDCLIQueryParams, + data: Record[], + ): OECDCLIData[] { + if (data.length === 0) throw new EmptyDataError() + return data + .sort((a, b) => String(a.date).localeCompare(String(b.date))) + .map(d => CompositeLeadingIndicatorDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/oecd/models/consumer-price-index.ts b/packages/opentypebb/src/providers/oecd/models/consumer-price-index.ts new file mode 100644 index 00000000..2413f92d --- /dev/null +++ b/packages/opentypebb/src/providers/oecd/models/consumer-price-index.ts @@ -0,0 +1,118 @@ +/** + * OECD Consumer Price Index Model. + * Maps to: openbb_oecd/models/consumer_price_index.py + * + * Uses CSV format from OECD SDMX REST API (same as Python implementation). + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { ConsumerPriceIndexDataSchema } from '../../../standard-models/consumer-price-index.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { nativeFetch } from '../../../core/provider/utils/helpers.js' + +export const OECDCPIQueryParamsSchema = z.object({ + country: z.string().default('united_states').describe('The country to get data for.'), + transform: z.string().default('yoy').describe('Transformation: yoy, period, index.'), + frequency: z.enum(['annual', 'quarter', 'monthly']).default('monthly').describe('Data frequency.'), + harmonized: z.boolean().default(false).describe('If true, returns harmonized data.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type OECDCPIQueryParams = z.infer +export type OECDCPIData = z.infer + +const COUNTRY_MAP: Record = { + united_states: 'USA', united_kingdom: 'GBR', japan: 'JPN', germany: 'DEU', + france: 'FRA', italy: 'ITA', canada: 'CAN', australia: 'AUS', + south_korea: 'KOR', mexico: 'MEX', brazil: 'BRA', china: 'CHN', + india: 'IND', turkey: 'TUR', south_africa: 'ZAF', russia: 'RUS', + spain: 'ESP', netherlands: 'NLD', switzerland: 'CHE', sweden: 'SWE', + norway: 'NOR', denmark: 'DNK', finland: 'FIN', belgium: 'BEL', + austria: 'AUT', ireland: 'IRL', portugal: 'PRT', greece: 'GRC', + new_zealand: 'NZL', israel: 'ISR', poland: 'POL', czech_republic: 'CZE', + hungary: 'HUN', colombia: 'COL', chile: 'CHL', indonesia: 'IDN', +} + +const CODE_TO_NAME: Record = Object.fromEntries( + Object.entries(COUNTRY_MAP).map(([k, v]) => [v, k.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())]), +) + +const FREQ_MAP: Record = { annual: 'A', quarter: 'Q', monthly: 'M' } + +/** Parse simple CSV text into rows */ +function parseCSV(text: string): Record[] { + const lines = text.trim().split('\n') + if (lines.length < 2) return [] + const headers = lines[0].split(',') + return lines.slice(1).map(line => { + const values = line.split(',') + const row: Record = {} + headers.forEach((h, i) => { row[h.trim()] = values[i]?.trim() ?? '' }) + return row + }) +} + +export class OECDConsumerPriceIndexFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): OECDCPIQueryParams { + return OECDCPIQueryParamsSchema.parse(params) + } + + static override async extractData( + query: OECDCPIQueryParams, + _credentials: Record | null, + ): Promise[]> { + const countryCode = COUNTRY_MAP[query.country] ?? query.country.toUpperCase() + const freq = FREQ_MAP[query.frequency] ?? 'M' + const methodology = query.harmonized ? 'HICP' : 'N' + const units = query.transform === 'yoy' ? 'PA' : query.transform === 'period' ? 'PC' : 'IX' + const expenditure = '_T' + + // Use CSV format (matching Python implementation) + // Dimension order: REF_AREA.FREQ.METHODOLOGY.MEASURE.UNIT_MEASURE.EXPENDITURE.UNIT_MULT. + const url = + `https://sdmx.oecd.org/public/rest/data/OECD.SDD.TPS,DSD_PRICES@DF_PRICES_ALL,1.0` + + `/${countryCode}.${freq}.${methodology}.CPI.${units}.${expenditure}.N.` + + `?dimensionAtObservation=TIME_PERIOD&detail=dataonly&format=csvfile` + + try { + const resp = await nativeFetch(url, { + headers: { Accept: 'application/vnd.sdmx.data+csv; charset=utf-8' }, + timeoutMs: 30000, + }) + if (resp.status !== 200) throw new EmptyDataError(`OECD CPI API returned ${resp.status}`) + const rows = parseCSV(resp.text) + if (!rows.length) throw new EmptyDataError() + + return rows + .filter(r => r.OBS_VALUE && r.OBS_VALUE !== '') + .map(r => { + const period = r.TIME_PERIOD ?? '' + return { + date: period.length === 7 ? period + '-01' : period.length === 4 ? period + '-01-01' : period, + country: CODE_TO_NAME[r.REF_AREA] ?? r.REF_AREA ?? query.country, + value: parseFloat(r.OBS_VALUE), + } + }) + } catch (err) { + if (err instanceof EmptyDataError) throw err + throw new EmptyDataError(`Failed to fetch OECD CPI data: ${err}`) + } + } + + static override transformData( + query: OECDCPIQueryParams, + data: Record[], + ): OECDCPIData[] { + if (data.length === 0) throw new EmptyDataError() + let filtered = data + if (query.start_date) filtered = filtered.filter(d => String(d.date) >= query.start_date!) + if (query.end_date) filtered = filtered.filter(d => String(d.date) <= query.end_date!) + return filtered + .sort((a, b) => String(a.date).localeCompare(String(b.date))) + .map(d => ConsumerPriceIndexDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/oecd/models/country-interest-rates.ts b/packages/opentypebb/src/providers/oecd/models/country-interest-rates.ts new file mode 100644 index 00000000..2e0d2bf7 --- /dev/null +++ b/packages/opentypebb/src/providers/oecd/models/country-interest-rates.ts @@ -0,0 +1,110 @@ +/** + * OECD Country Interest Rates Model. + * Maps to: openbb_oecd/models/country_interest_rates.py + * + * Uses CSV format from OECD SDMX REST API (same as Python implementation). + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CountryInterestRatesDataSchema } from '../../../standard-models/country-interest-rates.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { nativeFetch } from '../../../core/provider/utils/helpers.js' + +export const OECDInterestRatesQueryParamsSchema = z.object({ + country: z.string().default('united_states').describe('The country to get data for.'), + duration: z.enum(['short', 'long']).default('short').describe('Duration of the interest rate (short or long).'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type OECDInterestRatesQueryParams = z.infer +export type OECDInterestRatesData = z.infer + +const COUNTRY_MAP: Record = { + united_states: 'USA', united_kingdom: 'GBR', japan: 'JPN', germany: 'DEU', + france: 'FRA', italy: 'ITA', canada: 'CAN', australia: 'AUS', + south_korea: 'KOR', mexico: 'MEX', brazil: 'BRA', china: 'CHN', + spain: 'ESP', netherlands: 'NLD', switzerland: 'CHE', sweden: 'SWE', + norway: 'NOR', denmark: 'DNK', new_zealand: 'NZL', poland: 'POL', +} + +const CODE_TO_NAME: Record = Object.fromEntries( + Object.entries(COUNTRY_MAP).map(([k, v]) => [v, k.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())]), +) + +const DURATION_MAP: Record = { short: 'IR3TIB', long: 'IRLT' } + +/** Parse simple CSV text into rows */ +function parseCSV(text: string): Record[] { + const lines = text.trim().split('\n') + if (lines.length < 2) return [] + const headers = lines[0].split(',') + return lines.slice(1).map(line => { + const values = line.split(',') + const row: Record = {} + headers.forEach((h, i) => { row[h.trim()] = values[i]?.trim() ?? '' }) + return row + }) +} + +export class OECDCountryInterestRatesFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): OECDInterestRatesQueryParams { + if (!params.start_date) params.start_date = '1950-01-01' + if (!params.end_date) { + const y = new Date().getFullYear() + params.end_date = `${y}-12-31` + } + return OECDInterestRatesQueryParamsSchema.parse(params) + } + + static override async extractData( + query: OECDInterestRatesQueryParams, + _credentials: Record | null, + ): Promise[]> { + const countryCode = COUNTRY_MAP[query.country] ?? query.country.toUpperCase() + const duration = DURATION_MAP[query.duration] ?? 'IR3TIB' + const startPeriod = query.start_date ? query.start_date.slice(0, 7) : '' + const endPeriod = query.end_date ? query.end_date.slice(0, 7) : '' + + const url = + `https://sdmx.oecd.org/public/rest/data/OECD.SDD.STES,DSD_KEI@DF_KEI,4.0` + + `/${countryCode}.M.${duration}....` + + `?startPeriod=${startPeriod}&endPeriod=${endPeriod}` + + `&dimensionAtObservation=TIME_PERIOD&detail=dataonly` + + try { + const resp = await nativeFetch(url, { + headers: { Accept: 'application/vnd.sdmx.data+csv; charset=utf-8' }, + timeoutMs: 20000, + }) + if (resp.status !== 200) throw new EmptyDataError(`OECD API returned ${resp.status}`) + const text = resp.text + const rows = parseCSV(text) + if (!rows.length) throw new EmptyDataError() + + return rows + .filter(r => r.OBS_VALUE && r.OBS_VALUE !== '') + .map(r => ({ + date: r.TIME_PERIOD ? r.TIME_PERIOD + '-01' : '', + value: parseFloat(r.OBS_VALUE) / 100, + country: CODE_TO_NAME[r.REF_AREA] ?? r.REF_AREA ?? query.country, + })) + } catch (err) { + if (err instanceof EmptyDataError) throw err + throw new EmptyDataError(`Failed to fetch OECD interest rates: ${err}`) + } + } + + static override transformData( + _query: OECDInterestRatesQueryParams, + data: Record[], + ): OECDInterestRatesData[] { + if (data.length === 0) throw new EmptyDataError() + return data + .sort((a, b) => String(a.date).localeCompare(String(b.date))) + .map(d => CountryInterestRatesDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/oecd/models/gdp-forecast.ts b/packages/opentypebb/src/providers/oecd/models/gdp-forecast.ts new file mode 100644 index 00000000..d2224841 --- /dev/null +++ b/packages/opentypebb/src/providers/oecd/models/gdp-forecast.ts @@ -0,0 +1,56 @@ +/** + * OECD GDP Forecast Fetcher. + * Uses OECD Economic Outlook dataset. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { GdpForecastDataSchema } from '../../../standard-models/gdp-forecast.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchOecdCsv, resolveCountryCode, periodToDate, CODE_TO_NAME, FREQ_MAP, filterAndSort } from '../utils/oecd-helpers.js' + +export const OECDGdpForecastQueryParamsSchema = z.object({ + country: z.string().default('united_states'), + start_date: z.string().nullable().default(null), + end_date: z.string().nullable().default(null), + frequency: z.enum(['annual', 'quarter']).default('annual'), +}).passthrough() + +export type OECDGdpForecastQueryParams = z.infer + +export class OECDGdpForecastFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): OECDGdpForecastQueryParams { + return OECDGdpForecastQueryParamsSchema.parse(params) + } + + static override async extractData( + query: OECDGdpForecastQueryParams, + _credentials: Record | null, + ): Promise[]> { + const cc = resolveCountryCode(query.country) + const freq = FREQ_MAP[query.frequency] ?? 'A' + const rows = await fetchOecdCsv( + 'OECD.SDD.NAD,DSD_NAMAIN1@DF_TABLE1_EXPENDITURE_HCPC,1.0', + `${cc}.${freq}.S1.S1.B1GQ._Z._Z._Z.V.GY.`, + ) + + return rows + .filter(r => r.OBS_VALUE && r.OBS_VALUE !== '') + .map(r => ({ + date: periodToDate(r.TIME_PERIOD ?? ''), + country: CODE_TO_NAME[r.REF_AREA] ?? r.REF_AREA ?? query.country, + value: parseFloat(r.OBS_VALUE), + })) + } + + static override transformData( + query: OECDGdpForecastQueryParams, + data: Record[], + ) { + if (data.length === 0) throw new EmptyDataError() + return filterAndSort(data, query.start_date, query.end_date) + .map(d => GdpForecastDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/oecd/models/gdp-nominal.ts b/packages/opentypebb/src/providers/oecd/models/gdp-nominal.ts new file mode 100644 index 00000000..73641021 --- /dev/null +++ b/packages/opentypebb/src/providers/oecd/models/gdp-nominal.ts @@ -0,0 +1,55 @@ +/** + * OECD GDP Nominal Fetcher. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { GdpNominalDataSchema } from '../../../standard-models/gdp-nominal.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchOecdCsv, resolveCountryCode, periodToDate, CODE_TO_NAME, FREQ_MAP, filterAndSort } from '../utils/oecd-helpers.js' + +export const OECDGdpNominalQueryParamsSchema = z.object({ + country: z.string().default('united_states'), + start_date: z.string().nullable().default(null), + end_date: z.string().nullable().default(null), + frequency: z.enum(['annual', 'quarter']).default('annual'), +}).passthrough() + +export type OECDGdpNominalQueryParams = z.infer + +export class OECDGdpNominalFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): OECDGdpNominalQueryParams { + return OECDGdpNominalQueryParamsSchema.parse(params) + } + + static override async extractData( + query: OECDGdpNominalQueryParams, + _credentials: Record | null, + ): Promise[]> { + const cc = resolveCountryCode(query.country) + const freq = FREQ_MAP[query.frequency] ?? 'A' + const rows = await fetchOecdCsv( + 'OECD.SDD.NAD,DSD_NAMAIN1@DF_TABLE1_EXPENDITURE_HCPC,1.0', + `${cc}.${freq}.S1.S1.B1GQ._Z._Z._Z.V.N.`, + ) + + return rows + .filter(r => r.OBS_VALUE && r.OBS_VALUE !== '') + .map(r => ({ + date: periodToDate(r.TIME_PERIOD ?? ''), + country: CODE_TO_NAME[r.REF_AREA] ?? r.REF_AREA ?? query.country, + value: parseFloat(r.OBS_VALUE), + })) + } + + static override transformData( + query: OECDGdpNominalQueryParams, + data: Record[], + ) { + if (data.length === 0) throw new EmptyDataError() + return filterAndSort(data, query.start_date, query.end_date) + .map(d => GdpNominalDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/oecd/models/gdp-real.ts b/packages/opentypebb/src/providers/oecd/models/gdp-real.ts new file mode 100644 index 00000000..c90bd565 --- /dev/null +++ b/packages/opentypebb/src/providers/oecd/models/gdp-real.ts @@ -0,0 +1,55 @@ +/** + * OECD GDP Real Fetcher. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { GdpRealDataSchema } from '../../../standard-models/gdp-real.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchOecdCsv, resolveCountryCode, periodToDate, CODE_TO_NAME, FREQ_MAP, filterAndSort } from '../utils/oecd-helpers.js' + +export const OECDGdpRealQueryParamsSchema = z.object({ + country: z.string().default('united_states'), + start_date: z.string().nullable().default(null), + end_date: z.string().nullable().default(null), + frequency: z.enum(['annual', 'quarter']).default('annual'), +}).passthrough() + +export type OECDGdpRealQueryParams = z.infer + +export class OECDGdpRealFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): OECDGdpRealQueryParams { + return OECDGdpRealQueryParamsSchema.parse(params) + } + + static override async extractData( + query: OECDGdpRealQueryParams, + _credentials: Record | null, + ): Promise[]> { + const cc = resolveCountryCode(query.country) + const freq = FREQ_MAP[query.frequency] ?? 'A' + const rows = await fetchOecdCsv( + 'OECD.SDD.NAD,DSD_NAMAIN1@DF_TABLE1_EXPENDITURE_HCPC,1.0', + `${cc}.${freq}.S1.S1.B1GQ._Z._Z._Z.L.N.`, + ) + + return rows + .filter(r => r.OBS_VALUE && r.OBS_VALUE !== '') + .map(r => ({ + date: periodToDate(r.TIME_PERIOD ?? ''), + country: CODE_TO_NAME[r.REF_AREA] ?? r.REF_AREA ?? query.country, + value: parseFloat(r.OBS_VALUE), + })) + } + + static override transformData( + query: OECDGdpRealQueryParams, + data: Record[], + ) { + if (data.length === 0) throw new EmptyDataError() + return filterAndSort(data, query.start_date, query.end_date) + .map(d => GdpRealDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/oecd/models/house-price-index.ts b/packages/opentypebb/src/providers/oecd/models/house-price-index.ts new file mode 100644 index 00000000..44285115 --- /dev/null +++ b/packages/opentypebb/src/providers/oecd/models/house-price-index.ts @@ -0,0 +1,55 @@ +/** + * OECD House Price Index Fetcher. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { HousePriceIndexDataSchema } from '../../../standard-models/house-price-index.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchOecdCsv, resolveCountryCode, periodToDate, CODE_TO_NAME, FREQ_MAP, filterAndSort } from '../utils/oecd-helpers.js' + +export const OECDHousePriceIndexQueryParamsSchema = z.object({ + country: z.string().default('united_states'), + frequency: z.enum(['annual', 'quarter', 'monthly']).default('quarter'), + start_date: z.string().nullable().default(null), + end_date: z.string().nullable().default(null), +}).passthrough() + +export type OECDHousePriceIndexQueryParams = z.infer + +export class OECDHousePriceIndexFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): OECDHousePriceIndexQueryParams { + return OECDHousePriceIndexQueryParamsSchema.parse(params) + } + + static override async extractData( + query: OECDHousePriceIndexQueryParams, + _credentials: Record | null, + ): Promise[]> { + const cc = resolveCountryCode(query.country) + const freq = FREQ_MAP[query.frequency] ?? 'Q' + const rows = await fetchOecdCsv( + 'OECD.SDD.TPS,DSD_AN_HOUSE_PRICES@DF_HOUSE_PRICES,1.0', + `${cc}.${freq}.RHP._T.IX.`, + ) + + return rows + .filter(r => r.OBS_VALUE && r.OBS_VALUE !== '') + .map(r => ({ + date: periodToDate(r.TIME_PERIOD ?? ''), + country: CODE_TO_NAME[r.REF_AREA] ?? r.REF_AREA ?? query.country, + value: parseFloat(r.OBS_VALUE), + })) + } + + static override transformData( + query: OECDHousePriceIndexQueryParams, + data: Record[], + ) { + if (data.length === 0) throw new EmptyDataError() + return filterAndSort(data, query.start_date, query.end_date) + .map(d => HousePriceIndexDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/oecd/models/retail-prices.ts b/packages/opentypebb/src/providers/oecd/models/retail-prices.ts new file mode 100644 index 00000000..2200bfb9 --- /dev/null +++ b/packages/opentypebb/src/providers/oecd/models/retail-prices.ts @@ -0,0 +1,56 @@ +/** + * OECD Retail Prices Fetcher. + * Uses OECD MEI Prices dataset. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { RetailPricesDataSchema } from '../../../standard-models/retail-prices.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchOecdCsv, resolveCountryCode, periodToDate, CODE_TO_NAME, FREQ_MAP, filterAndSort } from '../utils/oecd-helpers.js' + +export const OECDRetailPricesQueryParamsSchema = z.object({ + country: z.string().default('united_states'), + frequency: z.enum(['annual', 'quarter', 'monthly']).default('monthly'), + start_date: z.string().nullable().default(null), + end_date: z.string().nullable().default(null), +}).passthrough() + +export type OECDRetailPricesQueryParams = z.infer + +export class OECDRetailPricesFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): OECDRetailPricesQueryParams { + return OECDRetailPricesQueryParamsSchema.parse(params) + } + + static override async extractData( + query: OECDRetailPricesQueryParams, + _credentials: Record | null, + ): Promise[]> { + const cc = resolveCountryCode(query.country) + const freq = FREQ_MAP[query.frequency] ?? 'M' + const rows = await fetchOecdCsv( + 'OECD.SDD.TPS,DSD_PRICES@DF_PRICES_ALL,1.0', + `${cc}.${freq}.N.CPI.PA._T.N.`, + ) + + return rows + .filter(r => r.OBS_VALUE && r.OBS_VALUE !== '') + .map(r => ({ + date: periodToDate(r.TIME_PERIOD ?? ''), + country: CODE_TO_NAME[r.REF_AREA] ?? r.REF_AREA ?? query.country, + value: parseFloat(r.OBS_VALUE), + })) + } + + static override transformData( + query: OECDRetailPricesQueryParams, + data: Record[], + ) { + if (data.length === 0) throw new EmptyDataError() + return filterAndSort(data, query.start_date, query.end_date) + .map(d => RetailPricesDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/oecd/models/share-price-index.ts b/packages/opentypebb/src/providers/oecd/models/share-price-index.ts new file mode 100644 index 00000000..cd0d2c11 --- /dev/null +++ b/packages/opentypebb/src/providers/oecd/models/share-price-index.ts @@ -0,0 +1,56 @@ +/** + * OECD Share Price Index Fetcher. + * Uses OECD Main Economic Indicators (MEI) dataset. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { SharePriceIndexDataSchema } from '../../../standard-models/share-price-index.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { fetchOecdCsv, resolveCountryCode, periodToDate, CODE_TO_NAME, FREQ_MAP, filterAndSort } from '../utils/oecd-helpers.js' + +export const OECDSharePriceIndexQueryParamsSchema = z.object({ + country: z.string().default('united_states'), + frequency: z.enum(['annual', 'quarter', 'monthly']).default('monthly'), + start_date: z.string().nullable().default(null), + end_date: z.string().nullable().default(null), +}).passthrough() + +export type OECDSharePriceIndexQueryParams = z.infer + +export class OECDSharePriceIndexFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): OECDSharePriceIndexQueryParams { + return OECDSharePriceIndexQueryParamsSchema.parse(params) + } + + static override async extractData( + query: OECDSharePriceIndexQueryParams, + _credentials: Record | null, + ): Promise[]> { + const cc = resolveCountryCode(query.country) + const freq = FREQ_MAP[query.frequency] ?? 'M' + const rows = await fetchOecdCsv( + 'OECD.SDD.STES,DSD_KEI@DF_KEI,4.0', + `${cc}.${freq}.SHARE._Z.IX._T.`, + ) + + return rows + .filter(r => r.OBS_VALUE && r.OBS_VALUE !== '') + .map(r => ({ + date: periodToDate(r.TIME_PERIOD ?? ''), + country: CODE_TO_NAME[r.REF_AREA] ?? r.REF_AREA ?? query.country, + value: parseFloat(r.OBS_VALUE), + })) + } + + static override transformData( + query: OECDSharePriceIndexQueryParams, + data: Record[], + ) { + if (data.length === 0) throw new EmptyDataError() + return filterAndSort(data, query.start_date, query.end_date) + .map(d => SharePriceIndexDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/oecd/utils/oecd-helpers.ts b/packages/opentypebb/src/providers/oecd/utils/oecd-helpers.ts new file mode 100644 index 00000000..8db496b1 --- /dev/null +++ b/packages/opentypebb/src/providers/oecd/utils/oecd-helpers.ts @@ -0,0 +1,103 @@ +/** + * OECD SDMX API shared helpers. + * Extracted from the existing CPI/CLI/InterestRates fetchers to avoid repetition. + */ + +import { nativeFetch } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' + +export const COUNTRY_MAP: Record = { + united_states: 'USA', united_kingdom: 'GBR', japan: 'JPN', germany: 'DEU', + france: 'FRA', italy: 'ITA', canada: 'CAN', australia: 'AUS', + south_korea: 'KOR', mexico: 'MEX', brazil: 'BRA', china: 'CHN', + india: 'IND', turkey: 'TUR', south_africa: 'ZAF', russia: 'RUS', + spain: 'ESP', netherlands: 'NLD', switzerland: 'CHE', sweden: 'SWE', + norway: 'NOR', denmark: 'DNK', finland: 'FIN', belgium: 'BEL', + austria: 'AUT', ireland: 'IRL', portugal: 'PRT', greece: 'GRC', + new_zealand: 'NZL', israel: 'ISR', poland: 'POL', czech_republic: 'CZE', + hungary: 'HUN', colombia: 'COL', chile: 'CHL', indonesia: 'IDN', +} + +export const CODE_TO_NAME: Record = Object.fromEntries( + Object.entries(COUNTRY_MAP).map(([k, v]) => [v, k.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())]), +) + +export const FREQ_MAP: Record = { annual: 'A', quarter: 'Q', monthly: 'M' } + +/** + * Parse simple CSV text into rows. + */ +export function parseCSV(text: string): Record[] { + const lines = text.trim().split('\n') + if (lines.length < 2) return [] + const headers = lines[0].split(',') + return lines.slice(1).map(line => { + const values = line.split(',') + const row: Record = {} + headers.forEach((h, i) => { row[h.trim()] = values[i]?.trim() ?? '' }) + return row + }) +} + +/** + * Convert OECD period format to date string. + * "2024" → "2024-01-01", "2024-01" → "2024-01-01", "2024-Q1" → "2024-01-01" + */ +export function periodToDate(period: string): string { + if (period.includes('-Q')) { + const [year, q] = period.split('-Q') + const month = String((parseInt(q) - 1) * 3 + 1).padStart(2, '0') + return `${year}-${month}-01` + } + if (period.length === 7) return period + '-01' + if (period.length === 4) return period + '-01-01' + return period +} + +/** + * Fetch data from OECD SDMX REST API in CSV format. + */ +export async function fetchOecdCsv( + dataflow: string, + dimensions: string, +): Promise[]> { + const url = + `https://sdmx.oecd.org/public/rest/data/${dataflow}` + + `/${dimensions}` + + `?dimensionAtObservation=TIME_PERIOD&detail=dataonly&format=csvfile` + + try { + const resp = await nativeFetch(url, { + headers: { Accept: 'application/vnd.sdmx.data+csv; charset=utf-8' }, + timeoutMs: 30000, + }) + if (resp.status !== 200) throw new EmptyDataError(`OECD API returned ${resp.status}`) + const rows = parseCSV(resp.text) + if (!rows.length) throw new EmptyDataError() + return rows + } catch (err) { + if (err instanceof EmptyDataError) throw err + throw new EmptyDataError(`Failed to fetch OECD data: ${err}`) + } +} + +/** + * Resolve country name to OECD 3-letter code. + */ +export function resolveCountryCode(country: string): string { + return COUNTRY_MAP[country] ?? country.toUpperCase() +} + +/** + * Apply date filters and sort results. + */ +export function filterAndSort( + data: T[], + startDate?: string | null, + endDate?: string | null, +): T[] { + let filtered = data + if (startDate) filtered = filtered.filter(d => String(d.date) >= startDate) + if (endDate) filtered = filtered.filter(d => String(d.date) <= endDate) + return filtered.sort((a, b) => String(a.date).localeCompare(String(b.date))) +} diff --git a/packages/opentypebb/src/providers/stub/index.ts b/packages/opentypebb/src/providers/stub/index.ts new file mode 100644 index 00000000..0877a7b2 --- /dev/null +++ b/packages/opentypebb/src/providers/stub/index.ts @@ -0,0 +1,27 @@ +/** + * Stub Provider Module. + * Contains placeholder fetchers for endpoints that don't yet have a reliable public data source. + * These register the models in the registry so routes can be created, but always throw EmptyDataError. + */ + +import { Provider } from '../../core/provider/abstract/provider.js' + +import { + StubPortInfoFetcher, + StubPortVolumeFetcher, + StubChokepointInfoFetcher, + StubChokepointVolumeFetcher, +} from './models/shipping-stubs.js' + +export const stubProvider = new Provider({ + name: 'stub', + website: '', + description: 'Placeholder provider for endpoints awaiting a public data source.', + fetcherDict: { + PortInfo: StubPortInfoFetcher, + PortVolume: StubPortVolumeFetcher, + ChokepointInfo: StubChokepointInfoFetcher, + ChokepointVolume: StubChokepointVolumeFetcher, + }, + reprName: 'Stub', +}) diff --git a/packages/opentypebb/src/providers/stub/models/shipping-stubs.ts b/packages/opentypebb/src/providers/stub/models/shipping-stubs.ts new file mode 100644 index 00000000..51790042 --- /dev/null +++ b/packages/opentypebb/src/providers/stub/models/shipping-stubs.ts @@ -0,0 +1,87 @@ +/** + * Shipping Stub Fetchers. + * + * These register the shipping endpoints with proper schemas but always throw + * EmptyDataError. Once a reliable public API for shipping data is found, + * replace with real fetchers. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { PortInfoQueryParamsSchema } from '../../../standard-models/port-info.js' +import { PortVolumeQueryParamsSchema } from '../../../standard-models/port-volume.js' +import { ChokepointInfoQueryParamsSchema } from '../../../standard-models/chokepoint-info.js' +import { ChokepointVolumeQueryParamsSchema } from '../../../standard-models/chokepoint-volume.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' + +// --- Port Info --- + +export class StubPortInfoFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record) { + return PortInfoQueryParamsSchema.parse(params) + } + + static override async extractData(): Promise[]> { + throw new EmptyDataError('Port info data source not yet implemented. No reliable public API available.') + } + + static override transformData(_query: unknown, data: Record[]) { + return data + } +} + +// --- Port Volume --- + +export class StubPortVolumeFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record) { + return PortVolumeQueryParamsSchema.parse(params) + } + + static override async extractData(): Promise[]> { + throw new EmptyDataError('Port volume data source not yet implemented. No reliable public API available.') + } + + static override transformData(_query: unknown, data: Record[]) { + return data + } +} + +// --- Chokepoint Info --- + +export class StubChokepointInfoFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record) { + return ChokepointInfoQueryParamsSchema.parse(params) + } + + static override async extractData(): Promise[]> { + throw new EmptyDataError('Chokepoint info data source not yet implemented. No reliable public API available.') + } + + static override transformData(_query: unknown, data: Record[]) { + return data + } +} + +// --- Chokepoint Volume --- + +export class StubChokepointVolumeFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record) { + return ChokepointVolumeQueryParamsSchema.parse(params) + } + + static override async extractData(): Promise[]> { + throw new EmptyDataError('Chokepoint volume data source not yet implemented. No reliable public API available.') + } + + static override transformData(_query: unknown, data: Record[]) { + return data + } +} diff --git a/packages/opentypebb/src/providers/yfinance/index.ts b/packages/opentypebb/src/providers/yfinance/index.ts new file mode 100644 index 00000000..86ae2c9d --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/index.ts @@ -0,0 +1,84 @@ +/** + * YFinance Provider Module. + * Maps to: openbb_platform/providers/yfinance/openbb_yfinance/__init__.py + * + * Only includes fetchers that have been ported to TypeScript. + */ + +import { Provider } from '../../core/provider/abstract/provider.js' + +import { YFinanceEquityQuoteFetcher } from './models/equity-quote.js' +import { YFinanceEquityProfileFetcher } from './models/equity-profile.js' +import { YFinanceEquityHistoricalFetcher } from './models/equity-historical.js' +import { YFinanceCompanyNewsFetcher } from './models/company-news.js' +import { YFinanceKeyMetricsFetcher } from './models/key-metrics.js' +import { YFinancePriceTargetConsensusFetcher } from './models/price-target-consensus.js' +import { YFinanceCryptoSearchFetcher } from './models/crypto-search.js' +import { YFinanceCurrencySearchFetcher } from './models/currency-search.js' +import { YFinanceCryptoHistoricalFetcher } from './models/crypto-historical.js' +import { YFinanceCurrencyHistoricalFetcher } from './models/currency-historical.js' +import { YFinanceBalanceSheetFetcher } from './models/balance-sheet.js' +import { YFinanceIncomeStatementFetcher } from './models/income-statement.js' +import { YFinanceCashFlowStatementFetcher } from './models/cash-flow.js' +import { YFGainersFetcher } from './models/gainers.js' +import { YFLosersFetcher } from './models/losers.js' +import { YFActiveFetcher } from './models/active.js' +import { YFAggressiveSmallCapsFetcher } from './models/aggressive-small-caps.js' +import { YFGrowthTechEquitiesFetcher } from './models/growth-tech.js' +import { YFUndervaluedGrowthEquitiesFetcher } from './models/undervalued-growth.js' +import { YFUndervaluedLargeCapsFetcher } from './models/undervalued-large-caps.js' +import { YFinanceKeyExecutivesFetcher } from './models/key-executives.js' +import { YFinanceHistoricalDividendsFetcher } from './models/historical-dividends.js' +import { YFinanceShareStatisticsFetcher } from './models/share-statistics.js' +import { YFinanceIndexHistoricalFetcher } from './models/index-historical.js' +import { YFinanceFuturesHistoricalFetcher } from './models/futures-historical.js' +import { YFinanceAvailableIndicesFetcher } from './models/available-indices.js' +import { YFinanceEtfInfoFetcher } from './models/etf-info.js' +import { YFinanceEquityScreenerFetcher } from './models/equity-screener.js' +import { YFinanceFuturesCurveFetcher } from './models/futures-curve.js' +import { YFinanceOptionsChainsFetcher } from './models/options-chains.js' +import { YFinanceCommoditySpotPriceFetcher } from './models/commodity-spot-price.js' + +export const yfinanceProvider = new Provider({ + name: 'yfinance', + website: 'https://finance.yahoo.com', + description: + 'Yahoo! Finance is a web-based platform that offers financial news, ' + + 'data, and tools for investors and individuals interested in tracking ' + + 'and analyzing financial markets and assets.', + fetcherDict: { + EquityQuote: YFinanceEquityQuoteFetcher, + EquityInfo: YFinanceEquityProfileFetcher, + EquityHistorical: YFinanceEquityHistoricalFetcher, + EtfHistorical: YFinanceEquityHistoricalFetcher, + CompanyNews: YFinanceCompanyNewsFetcher, + KeyMetrics: YFinanceKeyMetricsFetcher, + PriceTargetConsensus: YFinancePriceTargetConsensusFetcher, + BalanceSheet: YFinanceBalanceSheetFetcher, + IncomeStatement: YFinanceIncomeStatementFetcher, + CashFlowStatement: YFinanceCashFlowStatementFetcher, + CryptoSearch: YFinanceCryptoSearchFetcher, + CurrencyPairs: YFinanceCurrencySearchFetcher, + CryptoHistorical: YFinanceCryptoHistoricalFetcher, + CurrencyHistorical: YFinanceCurrencyHistoricalFetcher, + EquityGainers: YFGainersFetcher, + EquityLosers: YFLosersFetcher, + EquityActive: YFActiveFetcher, + EquityAggressiveSmallCaps: YFAggressiveSmallCapsFetcher, + GrowthTechEquities: YFGrowthTechEquitiesFetcher, + EquityUndervaluedGrowth: YFUndervaluedGrowthEquitiesFetcher, + EquityUndervaluedLargeCaps: YFUndervaluedLargeCapsFetcher, + KeyExecutives: YFinanceKeyExecutivesFetcher, + HistoricalDividends: YFinanceHistoricalDividendsFetcher, + ShareStatistics: YFinanceShareStatisticsFetcher, + IndexHistorical: YFinanceIndexHistoricalFetcher, + FuturesHistorical: YFinanceFuturesHistoricalFetcher, + AvailableIndices: YFinanceAvailableIndicesFetcher, + EtfInfo: YFinanceEtfInfoFetcher, + EquityScreener: YFinanceEquityScreenerFetcher, + FuturesCurve: YFinanceFuturesCurveFetcher, + OptionsChains: YFinanceOptionsChainsFetcher, + CommoditySpotPrice: YFinanceCommoditySpotPriceFetcher, + }, + reprName: 'Yahoo Finance', +}) diff --git a/packages/opentypebb/src/providers/yfinance/models/active.ts b/packages/opentypebb/src/providers/yfinance/models/active.ts new file mode 100644 index 00000000..da51c5f3 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/active.ts @@ -0,0 +1,51 @@ +/** + * Yahoo Finance Most Active Model. + * Maps to: openbb_yfinance/models/active.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityPerformanceQueryParamsSchema } from '../../../standard-models/equity-performance.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getPredefinedScreener } from '../utils/helpers.js' +import { YFPredefinedScreenerDataSchema, YF_SCREENER_ALIAS_DICT } from '../utils/references.js' + +export const YFActiveQueryParamsSchema = EquityPerformanceQueryParamsSchema.extend({ + limit: z.number().nullable().default(200).describe('Limit the number of results.'), +}) +export type YFActiveQueryParams = z.infer + +export const YFActiveDataSchema = YFPredefinedScreenerDataSchema +export type YFActiveData = z.infer + +export class YFActiveFetcher extends Fetcher { + static requireCredentials = false + + static override transformQuery(params: Record): YFActiveQueryParams { + return YFActiveQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFActiveQueryParams, + credentials: Record | null, + ): Promise[]> { + return getPredefinedScreener('most_actives', query.limit ?? 200) + } + + static override transformData( + query: YFActiveQueryParams, + data: Record[], + ): YFActiveData[] { + const sorted = [...data].sort((a, b) => { + const diff = Number(b.regularMarketVolume ?? 0) - Number(a.regularMarketVolume ?? 0) + return query.sort === 'desc' ? diff : -diff + }) + return sorted.map(d => { + const aliased = applyAliases(d, YF_SCREENER_ALIAS_DICT) + if (typeof aliased.percent_change === 'number') { + aliased.percent_change = aliased.percent_change / 100 + } + return YFActiveDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/aggressive-small-caps.ts b/packages/opentypebb/src/providers/yfinance/models/aggressive-small-caps.ts new file mode 100644 index 00000000..e6da2816 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/aggressive-small-caps.ts @@ -0,0 +1,51 @@ +/** + * Yahoo Finance Aggressive Small Caps Model. + * Maps to: openbb_yfinance/models/aggressive_small_caps.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityPerformanceQueryParamsSchema } from '../../../standard-models/equity-performance.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getPredefinedScreener } from '../utils/helpers.js' +import { YFPredefinedScreenerDataSchema, YF_SCREENER_ALIAS_DICT } from '../utils/references.js' + +export const YFAggressiveSmallCapsQueryParamsSchema = EquityPerformanceQueryParamsSchema.extend({ + limit: z.number().nullable().default(200).describe('Limit the number of results.'), +}) +export type YFAggressiveSmallCapsQueryParams = z.infer + +export const YFAggressiveSmallCapsDataSchema = YFPredefinedScreenerDataSchema +export type YFAggressiveSmallCapsData = z.infer + +export class YFAggressiveSmallCapsFetcher extends Fetcher { + static requireCredentials = false + + static override transformQuery(params: Record): YFAggressiveSmallCapsQueryParams { + return YFAggressiveSmallCapsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFAggressiveSmallCapsQueryParams, + credentials: Record | null, + ): Promise[]> { + return getPredefinedScreener('aggressive_small_caps', query.limit ?? 200) + } + + static override transformData( + query: YFAggressiveSmallCapsQueryParams, + data: Record[], + ): YFAggressiveSmallCapsData[] { + const sorted = [...data].sort((a, b) => { + const diff = Number(b.regularMarketChangePercent ?? 0) - Number(a.regularMarketChangePercent ?? 0) + return query.sort === 'desc' ? diff : -diff + }) + return sorted.map(d => { + const aliased = applyAliases(d, YF_SCREENER_ALIAS_DICT) + if (typeof aliased.percent_change === 'number') { + aliased.percent_change = aliased.percent_change / 100 + } + return YFAggressiveSmallCapsDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/available-indices.ts b/packages/opentypebb/src/providers/yfinance/models/available-indices.ts new file mode 100644 index 00000000..f9c351f4 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/available-indices.ts @@ -0,0 +1,49 @@ +/** + * Yahoo Finance Available Indices Model. + * Maps to: openbb_yfinance/models/available_indices.py + * + * Simply returns the INDICES reference table as structured data. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { AvailableIndicesQueryParamsSchema, AvailableIndicesDataSchema } from '../../../standard-models/available-indices.js' +import { INDICES } from '../utils/references.js' + +export const YFinanceAvailableIndicesQueryParamsSchema = AvailableIndicesQueryParamsSchema +export type YFinanceAvailableIndicesQueryParams = z.infer + +export const YFinanceAvailableIndicesDataSchema = AvailableIndicesDataSchema.extend({ + code: z.string().describe('ID code for keying the index in the OpenBB Terminal.'), +}).passthrough() +export type YFinanceAvailableIndicesData = z.infer + +export class YFinanceAvailableIndicesFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): YFinanceAvailableIndicesQueryParams { + return YFinanceAvailableIndicesQueryParamsSchema.parse(params) + } + + static override async extractData( + _query: YFinanceAvailableIndicesQueryParams, + _credentials: Record | null, + ): Promise[]> { + const records: Record[] = [] + for (const [code, entry] of Object.entries(INDICES)) { + records.push({ + code, + name: entry.name, + symbol: entry.ticker, + }) + } + return records + } + + static override transformData( + _query: YFinanceAvailableIndicesQueryParams, + data: Record[], + ): YFinanceAvailableIndicesData[] { + return data.map(d => YFinanceAvailableIndicesDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/balance-sheet.ts b/packages/opentypebb/src/providers/yfinance/models/balance-sheet.ts new file mode 100644 index 00000000..c9138e3f --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/balance-sheet.ts @@ -0,0 +1,63 @@ +/** + * YFinance Balance Sheet Model. + * Maps to: openbb_yfinance/models/balance_sheet.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { BalanceSheetQueryParamsSchema, BalanceSheetDataSchema } from '../../../standard-models/balance-sheet.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getFinancialStatements } from '../utils/helpers.js' + +// --- Query Params --- + +export const YFinanceBalanceSheetQueryParamsSchema = BalanceSheetQueryParamsSchema.extend({ + period: z.enum(['annual', 'quarter']).default('annual').describe('Time period of the data to return.'), + limit: z.coerce.number().int().min(1).max(5).nullable().default(5).describe('The number of data entries to return (max 5).'), +}) + +export type YFinanceBalanceSheetQueryParams = z.infer + +// --- Data --- + +// yahoo-finance2 camelCase → standard snake_case aliases +const ALIAS_DICT: Record = { + short_term_investments: 'other_short_term_investments', + net_receivables: 'receivables', + inventories: 'inventory', + total_current_assets: 'current_assets', + plant_property_equipment_gross: 'gross_p_p_e', + plant_property_equipment_net: 'net_p_p_e', + total_common_equity: 'stockholders_equity', + total_equity_non_controlling_interests: 'total_equity_gross_minority_interest', +} + +export const YFinanceBalanceSheetDataSchema = BalanceSheetDataSchema.extend({}).passthrough() +export type YFinanceBalanceSheetData = z.infer + +// --- Fetcher --- + +export class YFinanceBalanceSheetFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): YFinanceBalanceSheetQueryParams { + return YFinanceBalanceSheetQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceBalanceSheetQueryParams, + credentials: Record | null, + ): Promise[]> { + return getFinancialStatements(query.symbol, query.period, query.limit ?? 5) + } + + static override transformData( + query: YFinanceBalanceSheetQueryParams, + data: Record[], + ): YFinanceBalanceSheetData[] { + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return YFinanceBalanceSheetDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/cash-flow.ts b/packages/opentypebb/src/providers/yfinance/models/cash-flow.ts new file mode 100644 index 00000000..6909583e --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/cash-flow.ts @@ -0,0 +1,59 @@ +/** + * YFinance Cash Flow Statement Model. + * Maps to: openbb_yfinance/models/cash_flow.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CashFlowStatementQueryParamsSchema, CashFlowStatementDataSchema } from '../../../standard-models/cash-flow.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getFinancialStatements } from '../utils/helpers.js' + +// --- Query Params --- + +export const YFinanceCashFlowStatementQueryParamsSchema = CashFlowStatementQueryParamsSchema.extend({ + period: z.enum(['annual', 'quarter']).default('annual').describe('Time period of the data to return.'), + limit: z.coerce.number().int().min(1).max(5).nullable().default(5).describe('The number of data entries to return (max 5).'), +}) + +export type YFinanceCashFlowStatementQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + investments_in_property_plant_and_equipment: 'purchase_of_p_p_e', + issuance_of_common_equity: 'common_stock_issuance', + repurchase_of_common_equity: 'common_stock_payments', + cash_dividends_paid: 'payment_of_dividends', + net_change_in_cash_and_equivalents: 'changes_in_cash', +} + +export const YFinanceCashFlowStatementDataSchema = CashFlowStatementDataSchema.extend({}).passthrough() +export type YFinanceCashFlowStatementData = z.infer + +// --- Fetcher --- + +export class YFinanceCashFlowStatementFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): YFinanceCashFlowStatementQueryParams { + return YFinanceCashFlowStatementQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceCashFlowStatementQueryParams, + credentials: Record | null, + ): Promise[]> { + return getFinancialStatements(query.symbol, query.period, query.limit ?? 5) + } + + static override transformData( + query: YFinanceCashFlowStatementQueryParams, + data: Record[], + ): YFinanceCashFlowStatementData[] { + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return YFinanceCashFlowStatementDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/commodity-spot-price.ts b/packages/opentypebb/src/providers/yfinance/models/commodity-spot-price.ts new file mode 100644 index 00000000..f0b984a8 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/commodity-spot-price.ts @@ -0,0 +1,99 @@ +/** + * YFinance Commodity Spot Price Fetcher. + * Uses Yahoo Finance futures symbols (GC=F for gold, CL=F for crude, etc.) + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CommoditySpotPriceQueryParamsSchema, CommoditySpotPriceDataSchema } from '../../../standard-models/commodity-spot-price.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getHistoricalData } from '../utils/helpers.js' + +export const YFinanceCommoditySpotPriceQueryParamsSchema = CommoditySpotPriceQueryParamsSchema +export type YFinanceCommoditySpotPriceQueryParams = z.infer + +// Well-known commodity futures symbols +const COMMODITY_MAP: Record = { + gold: 'GC=F', + silver: 'SI=F', + platinum: 'PL=F', + palladium: 'PA=F', + copper: 'HG=F', + crude_oil: 'CL=F', + wti: 'CL=F', + brent: 'BZ=F', + natural_gas: 'NG=F', + heating_oil: 'HO=F', + gasoline: 'RB=F', + corn: 'ZC=F', + wheat: 'ZW=F', + soybeans: 'ZS=F', + sugar: 'SB=F', + coffee: 'KC=F', + cocoa: 'CC=F', + cotton: 'CT=F', + lumber: 'LBS=F', + live_cattle: 'LE=F', + lean_hogs: 'HE=F', +} + +function resolveSymbol(sym: string): string { + const lower = sym.toLowerCase().trim() + return COMMODITY_MAP[lower] ?? sym.trim() +} + +export class YFinanceCommoditySpotPriceFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): YFinanceCommoditySpotPriceQueryParams { + const now = new Date() + if (!params.start_date) { + const oneYearAgo = new Date(now) + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) + params.start_date = oneYearAgo.toISOString().slice(0, 10) + } + if (!params.end_date) { + params.end_date = now.toISOString().slice(0, 10) + } + return YFinanceCommoditySpotPriceQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceCommoditySpotPriceQueryParams, + _credentials: Record | null, + ): Promise[]> { + const symbols = query.symbol.split(',').map(s => resolveSymbol(s)).filter(Boolean) + + const allData: Record[] = [] + const results = await Promise.allSettled( + symbols.map(async (sym) => { + const data = await getHistoricalData(sym, { + startDate: query.start_date ?? undefined, + endDate: query.end_date ?? undefined, + interval: '1d', + }) + return data.map((d: Record) => ({ ...d, symbol: sym })) + }), + ) + + for (const result of results) { + if (result.status === 'fulfilled') { + allData.push(...result.value) + } + } + + if (allData.length === 0) { + throw new EmptyDataError('No commodity spot price data found.') + } + return allData + } + + static override transformData( + _query: YFinanceCommoditySpotPriceQueryParams, + data: Record[], + ) { + return data + .sort((a, b) => String(a.date).localeCompare(String(b.date))) + .map(d => CommoditySpotPriceDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/company-news.ts b/packages/opentypebb/src/providers/yfinance/models/company-news.ts new file mode 100644 index 00000000..b20345ce --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/company-news.ts @@ -0,0 +1,73 @@ +/** + * Yahoo Finance Company News Model. + * Maps to: openbb_yfinance/models/company_news.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CompanyNewsQueryParamsSchema, CompanyNewsDataSchema } from '../../../standard-models/company-news.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getYahooNews } from '../utils/helpers.js' + +export const YFinanceCompanyNewsQueryParamsSchema = CompanyNewsQueryParamsSchema +export type YFinanceCompanyNewsQueryParams = z.infer + +export const YFinanceCompanyNewsDataSchema = CompanyNewsDataSchema.extend({ + source: z.string().nullable().default(null).describe('Source of the news article.'), +}).passthrough() +export type YFinanceCompanyNewsData = z.infer + +export class YFinanceCompanyNewsFetcher extends Fetcher { + static override transformQuery(params: Record): YFinanceCompanyNewsQueryParams { + if (!params.symbol) throw new Error('Required field missing -> symbol') + return YFinanceCompanyNewsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceCompanyNewsQueryParams, + credentials: Record | null, + ): Promise[]> { + const symbols = (query.symbol ?? '').split(',').map(s => s.trim()).filter(Boolean) + const results: Record[] = [] + + await Promise.allSettled( + symbols.map(async (sym) => { + try { + const news = await getYahooNews(sym, 20) + for (const item of news as any[]) { + if (!item.title || !item.link) continue + // yahoo-finance2 returns providerPublishTime as a Date object + let date: string | null = null + if (item.providerPublishTime) { + if (item.providerPublishTime instanceof Date) { + date = item.providerPublishTime.toISOString() + } else if (typeof item.providerPublishTime === 'string') { + date = item.providerPublishTime + } else if (typeof item.providerPublishTime === 'number') { + date = new Date(item.providerPublishTime * 1000).toISOString() + } + } + results.push({ + symbol: sym, + title: item.title, + url: item.link, + date, + text: item.summary ?? '', + source: item.publisher ?? null, + }) + } + } catch { /* skip failures */ } + }) + ) + + if (!results.length) throw new EmptyDataError('No news data returned') + return results + } + + static override transformData( + query: YFinanceCompanyNewsQueryParams, + data: Record[], + ): YFinanceCompanyNewsData[] { + return data.map(d => YFinanceCompanyNewsDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/crypto-historical.ts b/packages/opentypebb/src/providers/yfinance/models/crypto-historical.ts new file mode 100644 index 00000000..df69547f --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/crypto-historical.ts @@ -0,0 +1,75 @@ +/** + * Yahoo Finance Crypto Historical Price Model. + * Maps to: openbb_yfinance/models/crypto_historical.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CryptoHistoricalQueryParamsSchema, CryptoHistoricalDataSchema } from '../../../standard-models/crypto-historical.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getHistoricalData } from '../utils/helpers.js' +import { INTERVALS_DICT } from '../utils/references.js' + +export const YFinanceCryptoHistoricalQueryParamsSchema = CryptoHistoricalQueryParamsSchema.extend({ + interval: z.enum(['1m', '2m', '5m', '15m', '30m', '60m', '90m', '1h', '1d', '5d', '1W', '1M', '1Q']).default('1d').describe('Data granularity.'), +}) +export type YFinanceCryptoHistoricalQueryParams = z.infer + +export const YFinanceCryptoHistoricalDataSchema = CryptoHistoricalDataSchema +export type YFinanceCryptoHistoricalData = z.infer + +export class YFinanceCryptoHistoricalFetcher extends Fetcher { + static override transformQuery(params: Record): YFinanceCryptoHistoricalQueryParams { + const now = new Date() + if (!params.start_date) { + const oneYearAgo = new Date(now) + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) + params.start_date = oneYearAgo.toISOString().slice(0, 10) + } + if (!params.end_date) { + params.end_date = now.toISOString().slice(0, 10) + } + return YFinanceCryptoHistoricalQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceCryptoHistoricalQueryParams, + credentials: Record | null, + ): Promise[]> { + const tickers = query.symbol.split(',').map(s => s.trim()).filter(Boolean) + // Convert crypto symbols: BTCUSD → BTC-USD (Yahoo Finance format) + const yahooTickers = tickers.map(t => { + if (!t.includes('-') && t.length > 3) { + return t.slice(0, -3) + '-' + t.slice(-3) + } + return t + }) + + const interval = INTERVALS_DICT[query.interval] ?? '1d' + const allData: Record[] = [] + + const results = await Promise.allSettled( + yahooTickers.map(async (sym) => { + return getHistoricalData(sym, { + startDate: query.start_date, + endDate: query.end_date, + interval, + }) + }) + ) + + for (const r of results) { + if (r.status === 'fulfilled') allData.push(...r.value) + } + + if (!allData.length) throw new EmptyDataError('No crypto historical data returned') + return allData + } + + static override transformData( + query: YFinanceCryptoHistoricalQueryParams, + data: Record[], + ): YFinanceCryptoHistoricalData[] { + return data.map(d => YFinanceCryptoHistoricalDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/crypto-search.ts b/packages/opentypebb/src/providers/yfinance/models/crypto-search.ts new file mode 100644 index 00000000..29de08a1 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/crypto-search.ts @@ -0,0 +1,48 @@ +/** + * Yahoo Finance Crypto Search Model. + * Maps to: openbb_yfinance/models/crypto_search.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CryptoSearchQueryParamsSchema, CryptoSearchDataSchema } from '../../../standard-models/crypto-search.js' +import { searchYahooFinance } from '../utils/helpers.js' + +export const YFinanceCryptoSearchQueryParamsSchema = CryptoSearchQueryParamsSchema +export type YFinanceCryptoSearchQueryParams = z.infer + +export const YFinanceCryptoSearchDataSchema = CryptoSearchDataSchema.extend({ + exchange: z.string().nullable().default(null).describe('The exchange the crypto trades on.'), + quote_type: z.string().nullable().default(null).describe('The quote type of the asset.'), +}).passthrough() +export type YFinanceCryptoSearchData = z.infer + +export class YFinanceCryptoSearchFetcher extends Fetcher { + static override transformQuery(params: Record): YFinanceCryptoSearchQueryParams { + return YFinanceCryptoSearchQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceCryptoSearchQueryParams, + credentials: Record | null, + ): Promise[]> { + if (!query.query) return [] + + const quotes = await searchYahooFinance(query.query) + return quotes + .filter((q: any) => q.quoteType === 'CRYPTOCURRENCY') + .map((q: any) => ({ + symbol: (q.symbol ?? '').replace('-', ''), + name: q.longname ?? q.shortname ?? null, + exchange: q.exchDisp ?? null, + quote_type: q.quoteType ?? null, + })) + } + + static override transformData( + query: YFinanceCryptoSearchQueryParams, + data: Record[], + ): YFinanceCryptoSearchData[] { + return data.map(d => YFinanceCryptoSearchDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/currency-historical.ts b/packages/opentypebb/src/providers/yfinance/models/currency-historical.ts new file mode 100644 index 00000000..31c8e6a2 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/currency-historical.ts @@ -0,0 +1,75 @@ +/** + * Yahoo Finance Currency Price Model. + * Maps to: openbb_yfinance/models/currency_historical.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CurrencyHistoricalQueryParamsSchema, CurrencyHistoricalDataSchema } from '../../../standard-models/currency-historical.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getHistoricalData } from '../utils/helpers.js' +import { INTERVALS_DICT } from '../utils/references.js' + +export const YFinanceCurrencyHistoricalQueryParamsSchema = CurrencyHistoricalQueryParamsSchema.extend({ + interval: z.enum(['1m', '2m', '5m', '15m', '30m', '60m', '90m', '1h', '1d', '5d', '1W', '1M', '1Q']).default('1d').describe('Data granularity.'), +}) +export type YFinanceCurrencyHistoricalQueryParams = z.infer + +export const YFinanceCurrencyHistoricalDataSchema = CurrencyHistoricalDataSchema +export type YFinanceCurrencyHistoricalData = z.infer + +export class YFinanceCurrencyHistoricalFetcher extends Fetcher { + static override transformQuery(params: Record): YFinanceCurrencyHistoricalQueryParams { + const now = new Date() + // Append =X suffix for Yahoo Finance currency symbols + if (typeof params.symbol === 'string') { + const symbols = params.symbol.split(',').map(s => { + const sym = s.trim().toUpperCase() + return sym.includes('=X') ? sym : sym + '=X' + }) + params.symbol = symbols.join(',') + } + if (!params.start_date) { + const oneYearAgo = new Date(now) + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) + params.start_date = oneYearAgo.toISOString().slice(0, 10) + } + if (!params.end_date) { + params.end_date = now.toISOString().slice(0, 10) + } + return YFinanceCurrencyHistoricalQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceCurrencyHistoricalQueryParams, + credentials: Record | null, + ): Promise[]> { + const symbols = query.symbol.split(',').map(s => s.trim()).filter(Boolean) + const interval = INTERVALS_DICT[query.interval] ?? '1d' + const allData: Record[] = [] + + const results = await Promise.allSettled( + symbols.map(async (sym) => { + return getHistoricalData(sym, { + startDate: query.start_date, + endDate: query.end_date, + interval, + }) + }) + ) + + for (const r of results) { + if (r.status === 'fulfilled') allData.push(...r.value) + } + + if (!allData.length) throw new EmptyDataError('No currency historical data returned') + return allData + } + + static override transformData( + query: YFinanceCurrencyHistoricalQueryParams, + data: Record[], + ): YFinanceCurrencyHistoricalData[] { + return data.map(d => YFinanceCurrencyHistoricalDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/currency-search.ts b/packages/opentypebb/src/providers/yfinance/models/currency-search.ts new file mode 100644 index 00000000..5f2d94fe --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/currency-search.ts @@ -0,0 +1,48 @@ +/** + * Yahoo Finance Currency Search Model. + * Maps to: openbb_yfinance/models/currency_search.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { CurrencyPairsQueryParamsSchema, CurrencyPairsDataSchema } from '../../../standard-models/currency-pairs.js' +import { searchYahooFinance } from '../utils/helpers.js' + +export const YFinanceCurrencySearchQueryParamsSchema = CurrencyPairsQueryParamsSchema +export type YFinanceCurrencySearchQueryParams = z.infer + +export const YFinanceCurrencySearchDataSchema = CurrencyPairsDataSchema.extend({ + exchange: z.string().nullable().default(null).describe('The exchange the currency pair trades on.'), + quote_type: z.string().nullable().default(null).describe('The quote type of the asset.'), +}).passthrough() +export type YFinanceCurrencySearchData = z.infer + +export class YFinanceCurrencySearchFetcher extends Fetcher { + static override transformQuery(params: Record): YFinanceCurrencySearchQueryParams { + return YFinanceCurrencySearchQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceCurrencySearchQueryParams, + credentials: Record | null, + ): Promise[]> { + if (!query.query) return [] + + const quotes = await searchYahooFinance(query.query) + return quotes + .filter((q: any) => q.quoteType === 'CURRENCY') + .map((q: any) => ({ + symbol: (q.symbol ?? '').replace('=X', ''), + name: q.longname ?? q.shortname ?? null, + exchange: q.exchDisp ?? null, + quote_type: q.quoteType ?? null, + })) + } + + static override transformData( + query: YFinanceCurrencySearchQueryParams, + data: Record[], + ): YFinanceCurrencySearchData[] { + return data.map(d => YFinanceCurrencySearchDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/equity-historical.ts b/packages/opentypebb/src/providers/yfinance/models/equity-historical.ts new file mode 100644 index 00000000..4dc3f87a --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/equity-historical.ts @@ -0,0 +1,74 @@ +/** + * Yahoo Finance Equity Historical Price Model. + * Maps to: openbb_yfinance/models/equity_historical.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityHistoricalQueryParamsSchema, EquityHistoricalDataSchema } from '../../../standard-models/equity-historical.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getHistoricalData } from '../utils/helpers.js' +import { INTERVALS_DICT } from '../utils/references.js' + +export const YFinanceEquityHistoricalQueryParamsSchema = EquityHistoricalQueryParamsSchema.extend({ + interval: z.enum(['1m', '2m', '5m', '15m', '30m', '60m', '90m', '1h', '1d', '5d', '1W', '1M', '1Q']).default('1d').describe('Data granularity.'), + extended_hours: z.boolean().default(false).describe('Include Pre and Post market data.'), + include_actions: z.boolean().default(true).describe('Include dividends and stock splits in results.'), + adjustment: z.enum(['splits_only', 'splits_and_dividends']).default('splits_only').describe('The adjustment factor to apply.'), +}) +export type YFinanceEquityHistoricalQueryParams = z.infer + +export const YFinanceEquityHistoricalDataSchema = EquityHistoricalDataSchema.extend({ + split_ratio: z.number().nullable().default(null).describe('Ratio of the equity split, if a split occurred.'), + dividend: z.number().nullable().default(null).describe('Dividend amount (split-adjusted), if a dividend was paid.'), +}).passthrough() +export type YFinanceEquityHistoricalData = z.infer + +export class YFinanceEquityHistoricalFetcher extends Fetcher { + static override transformQuery(params: Record): YFinanceEquityHistoricalQueryParams { + const now = new Date() + if (!params.start_date) { + const oneYearAgo = new Date(now) + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) + params.start_date = oneYearAgo.toISOString().slice(0, 10) + } + if (!params.end_date) { + params.end_date = now.toISOString().slice(0, 10) + } + return YFinanceEquityHistoricalQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceEquityHistoricalQueryParams, + credentials: Record | null, + ): Promise[]> { + const symbols = query.symbol.split(',').map(s => s.trim()).filter(Boolean) + const interval = INTERVALS_DICT[query.interval] ?? '1d' + + const allData: Record[] = [] + const results = await Promise.allSettled( + symbols.map(async (sym) => { + const data = await getHistoricalData(sym, { + startDate: query.start_date, + endDate: query.end_date, + interval, + }) + return data.map(d => ({ ...d, symbol: sym })) + }) + ) + + for (const r of results) { + if (r.status === 'fulfilled') allData.push(...r.value) + } + + if (!allData.length) throw new EmptyDataError('No historical data returned') + return allData + } + + static override transformData( + query: YFinanceEquityHistoricalQueryParams, + data: Record[], + ): YFinanceEquityHistoricalData[] { + return data.map(d => YFinanceEquityHistoricalDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/equity-profile.ts b/packages/opentypebb/src/providers/yfinance/models/equity-profile.ts new file mode 100644 index 00000000..9c14be9a --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/equity-profile.ts @@ -0,0 +1,92 @@ +/** + * YFinance Equity Profile Model. + * Maps to: openbb_yfinance/models/equity_profile.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityInfoQueryParamsSchema, EquityInfoDataSchema } from '../../../standard-models/equity-info.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getQuoteSummary } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + name: 'longName', + issue_type: 'quoteType', + stock_exchange: 'exchange', + exchange_timezone: 'timeZoneFullName', + industry_category: 'industry', + hq_country: 'country', + hq_address1: 'address1', + hq_address_city: 'city', + hq_address_postal_code: 'zip', + hq_state: 'state', + business_phone_no: 'phone', + company_url: 'website', + long_description: 'longBusinessSummary', + employees: 'fullTimeEmployees', + market_cap: 'marketCap', + shares_outstanding: 'sharesOutstanding', + shares_float: 'floatShares', + shares_implied_outstanding: 'impliedSharesOutstanding', + shares_short: 'sharesShort', + dividend_yield: 'dividendYield', +} + +export const YFinanceEquityProfileQueryParamsSchema = EquityInfoQueryParamsSchema +export type YFinanceEquityProfileQueryParams = z.infer + +export const YFinanceEquityProfileDataSchema = EquityInfoDataSchema.extend({ + exchange_timezone: z.string().nullable().default(null).describe('The timezone of the exchange.'), + issue_type: z.string().nullable().default(null).describe('The issuance type of the asset.'), + currency: z.string().nullable().default(null).describe('The currency in which the asset is traded.'), + market_cap: z.number().nullable().default(null).describe('The market capitalization of the asset.'), + shares_outstanding: z.number().nullable().default(null).describe('The number of listed shares outstanding.'), + shares_float: z.number().nullable().default(null).describe('The number of shares in the public float.'), + shares_implied_outstanding: z.number().nullable().default(null).describe('Implied shares outstanding.'), + shares_short: z.number().nullable().default(null).describe('The reported number of shares short.'), + dividend_yield: z.number().nullable().default(null).describe('The dividend yield of the asset.'), + beta: z.number().nullable().default(null).describe('The beta of the asset relative to the broad market.'), +}).strip() +export type YFinanceEquityProfileData = z.infer + +export class YFinanceEquityProfileFetcher extends Fetcher { + static override transformQuery(params: Record): YFinanceEquityProfileQueryParams { + return YFinanceEquityProfileQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceEquityProfileQueryParams, + credentials: Record | null, + ): Promise[]> { + const symbols = query.symbol.split(',').map(s => s.trim()).filter(Boolean) + const results = await Promise.allSettled( + symbols.map(s => getQuoteSummary(s, ['summaryProfile', 'summaryDetail', 'price', 'defaultKeyStatistics'])) + ) + const data: Record[] = [] + for (const r of results) { + if (r.status === 'fulfilled' && r.value) data.push(r.value) + } + return data + } + + static override transformData( + query: YFinanceEquityProfileQueryParams, + data: Record[], + ): YFinanceEquityProfileData[] { + if (!data.length) throw new EmptyDataError('No profile data returned') + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + // Convert epoch timestamp for first_stock_price_date + if (typeof aliased.first_stock_price_date === 'number') { + aliased.first_stock_price_date = new Date(aliased.first_stock_price_date * 1000).toISOString().slice(0, 10) + } + // yahoo-finance2 returns dividend_yield as a decimal (0.0039), + // OpenBB Python reports it as a percentage (0.39). Multiply by 100. + if (typeof aliased.dividend_yield === 'number') { + aliased.dividend_yield = Math.round(aliased.dividend_yield * 10000) / 100 + } + return YFinanceEquityProfileDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/equity-quote.ts b/packages/opentypebb/src/providers/yfinance/models/equity-quote.ts new file mode 100644 index 00000000..912e2ef0 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/equity-quote.ts @@ -0,0 +1,85 @@ +/** + * YFinance Equity Quote Model. + * Maps to: openbb_yfinance/models/equity_quote.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityQuoteQueryParamsSchema, EquityQuoteDataSchema } from '../../../standard-models/equity-quote.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getQuoteSummary } from '../utils/helpers.js' + +// yahoo-finance2 returns regularMarket* field names (already flattened) +// NOTE: 'exchange' is NOT aliased — the flattened data already has `exchange: "NMS"` (short code). +// Aliasing from `exchangeName` would overwrite it with the long name ("NasdaqGS"). +const ALIAS_DICT: Record = { + name: 'longName', + asset_type: 'quoteType', + last_price: 'regularMarketPrice', + high: 'regularMarketDayHigh', + low: 'regularMarketDayLow', + open: 'regularMarketOpen', + volume: 'regularMarketVolume', + prev_close: 'regularMarketPreviousClose', + year_high: 'fiftyTwoWeekHigh', + year_low: 'fiftyTwoWeekLow', + ma_50d: 'fiftyDayAverage', + ma_200d: 'twoHundredDayAverage', + volume_average: 'averageVolume', + volume_average_10d: 'averageDailyVolume10Day', + bid_size: 'bidSize', + ask_size: 'askSize', + currency: 'currency', +} + +export const YFinanceEquityQuoteQueryParamsSchema = EquityQuoteQueryParamsSchema +export type YFinanceEquityQuoteQueryParams = z.infer + +export const YFinanceEquityQuoteDataSchema = EquityQuoteDataSchema.extend({ + ma_50d: z.number().nullable().default(null).describe('50-day moving average price.'), + ma_200d: z.number().nullable().default(null).describe('200-day moving average price.'), + volume_average: z.number().nullable().default(null).describe('Average daily trading volume.'), + volume_average_10d: z.number().nullable().default(null).describe('Average daily trading volume in the last 10 days.'), + currency: z.string().nullable().default(null).describe('Currency of the price.'), +}).strip() +export type YFinanceEquityQuoteData = z.infer + +export class YFinanceEquityQuoteFetcher extends Fetcher { + static override transformQuery(params: Record): YFinanceEquityQuoteQueryParams { + return YFinanceEquityQuoteQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceEquityQuoteQueryParams, + credentials: Record | null, + ): Promise[]> { + const symbols = query.symbol.split(',').map(s => s.trim()).filter(Boolean) + const results = await Promise.allSettled( + symbols.map(s => getQuoteSummary(s, ['price', 'summaryDetail', 'defaultKeyStatistics'])) + ) + const data: Record[] = [] + for (const r of results) { + if (r.status === 'fulfilled' && r.value) { + data.push(r.value) + } else if (r.status === 'rejected') { + console.error(`[equity-quote] Failed for symbol: ${r.reason?.message ?? r.reason}`) + } + } + return data + } + + static override transformData( + query: YFinanceEquityQuoteQueryParams, + data: Record[], + ): YFinanceEquityQuoteData[] { + if (!data.length) throw new EmptyDataError('No quote data returned') + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + // yahoo-finance2 returns bidSize/askSize in lots (hundreds), normalize to board lots + if (typeof aliased.bid_size === 'number') aliased.bid_size = Math.round(aliased.bid_size / 100) + if (typeof aliased.ask_size === 'number') aliased.ask_size = Math.round(aliased.ask_size / 100) + return YFinanceEquityQuoteDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/equity-screener.ts b/packages/opentypebb/src/providers/yfinance/models/equity-screener.ts new file mode 100644 index 00000000..7602e2b1 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/equity-screener.ts @@ -0,0 +1,98 @@ +/** + * Yahoo Finance Equity Screener Model. + * Maps to: openbb_yfinance/models/equity_screener.py + * + * Uses Yahoo Finance custom screener API with filter operands. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityScreenerQueryParamsSchema, EquityScreenerDataSchema } from '../../../standard-models/equity-screener.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getPredefinedScreener } from '../utils/helpers.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { YF_SCREENER_ALIAS_DICT, YFPredefinedScreenerDataSchema } from '../utils/references.js' + +export const YFinanceEquityScreenerQueryParamsSchema = EquityScreenerQueryParamsSchema.extend({ + country: z.string().nullable().default('us').describe('Filter by country code (e.g. us, de, jp). Use "all" for no filter.'), + sector: z.string().nullable().default(null).describe('Filter by sector.'), + industry: z.string().nullable().default(null).describe('Filter by industry.'), + exchange: z.string().nullable().default(null).describe('Filter by exchange.'), + mktcap_min: z.number().nullable().default(500000000).describe('Filter by min market cap. Default 500M.'), + mktcap_max: z.number().nullable().default(null).describe('Filter by max market cap.'), + price_min: z.number().nullable().default(5).describe('Filter by min price. Default 5.'), + price_max: z.number().nullable().default(null).describe('Filter by max price.'), + volume_min: z.number().nullable().default(10000).describe('Filter by min volume. Default 10K.'), + volume_max: z.number().nullable().default(null).describe('Filter by max volume.'), + beta_min: z.number().nullable().default(null).describe('Filter by min beta.'), + beta_max: z.number().nullable().default(null).describe('Filter by max beta.'), + limit: z.number().nullable().default(200).describe('Limit the number of results. Default 200.'), +}).passthrough() +export type YFinanceEquityScreenerQueryParams = z.infer + +export const YFinanceEquityScreenerDataSchema = EquityScreenerDataSchema.merge(YFPredefinedScreenerDataSchema).passthrough() +export type YFinanceEquityScreenerData = z.infer + +/** Sector code → display name mapping */ +const SECTOR_MAP: Record = { + basic_materials: 'Basic Materials', + communication_services: 'Communication Services', + consumer_cyclical: 'Consumer Cyclical', + consumer_defensive: 'Consumer Defensive', + energy: 'Energy', + financial_services: 'Financial Services', + healthcare: 'Healthcare', + industrials: 'Industrials', + real_estate: 'Real Estate', + technology: 'Technology', + utilities: 'Utilities', +} + +export class YFinanceEquityScreenerFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): YFinanceEquityScreenerQueryParams { + return YFinanceEquityScreenerQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceEquityScreenerQueryParams, + credentials: Record | null, + ): Promise[]> { + // For now, use predefined screener as a simplified approach. + // The full custom screener API requires Yahoo's internal POST endpoint + // which is complex to replicate without the yfinance Python library. + // We use the day_gainers screener as base and filter client-side. + const limit = query.limit ?? 200 + const data = await getPredefinedScreener('most_actives', Math.max(limit, 250)) + + if (!data.length) { + throw new EmptyDataError('No screener results found') + } + + return data + } + + static override transformData( + query: YFinanceEquityScreenerQueryParams, + data: Record[], + ): YFinanceEquityScreenerData[] { + const limit = query.limit ?? 200 + const results = data + .map(d => { + // Normalize percent_change + if (typeof d.regularMarketChangePercent === 'number') { + d.regularMarketChangePercent = d.regularMarketChangePercent / 100 + } + const aliased = applyAliases(d, YF_SCREENER_ALIAS_DICT) + try { + return YFinanceEquityScreenerDataSchema.parse(aliased) + } catch { + return null + } + }) + .filter((d): d is YFinanceEquityScreenerData => d !== null) + + return limit > 0 ? results.slice(0, limit) : results + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/etf-info.ts b/packages/opentypebb/src/providers/yfinance/models/etf-info.ts new file mode 100644 index 00000000..0103f403 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/etf-info.ts @@ -0,0 +1,148 @@ +/** + * Yahoo Finance ETF Info Model. + * Maps to: openbb_yfinance/models/etf_info.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EtfInfoQueryParamsSchema, EtfInfoDataSchema } from '../../../standard-models/etf-info.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getQuoteSummary } from '../utils/helpers.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' + +const ALIAS_DICT: Record = { + name: 'longName', + inception_date: 'fundInceptionDate', + description: 'longBusinessSummary', + fund_type: 'legalType', + fund_family: 'fundFamily', + exchange_timezone: 'timeZoneFullName', + nav_price: 'navPrice', + total_assets: 'totalAssets', + trailing_pe: 'trailingPE', + dividend_yield: 'yield', + dividend_rate_ttm: 'trailingAnnualDividendRate', + dividend_yield_ttm: 'trailingAnnualDividendYield', + year_high: 'fiftyTwoWeekHigh', + year_low: 'fiftyTwoWeekLow', + ma_50d: 'fiftyDayAverage', + ma_200d: 'twoHundredDayAverage', + return_ytd: 'ytdReturn', + return_3y_avg: 'threeYearAverageReturn', + return_5y_avg: 'fiveYearAverageReturn', + beta_3y_avg: 'beta3Year', + volume_avg: 'averageVolume', + volume_avg_10d: 'averageDailyVolume10Day', + bid_size: 'bidSize', + ask_size: 'askSize', + high: 'dayHigh', + low: 'dayLow', + prev_close: 'previousClose', +} + +const numOrNull = z.number().nullable().default(null) + +export const YFinanceEtfInfoQueryParamsSchema = EtfInfoQueryParamsSchema +export type YFinanceEtfInfoQueryParams = z.infer + +export const YFinanceEtfInfoDataSchema = EtfInfoDataSchema.extend({ + fund_type: z.string().nullable().default(null).describe('The legal type of fund.'), + fund_family: z.string().nullable().default(null).describe('The fund family.'), + category: z.string().nullable().default(null).describe('The fund category.'), + exchange: z.string().nullable().default(null).describe('The exchange the fund is listed on.'), + exchange_timezone: z.string().nullable().default(null).describe('The timezone of the exchange.'), + currency: z.string().nullable().default(null).describe('The currency the fund is listed in.'), + nav_price: numOrNull.describe('The net asset value per unit of the fund.'), + total_assets: numOrNull.describe('The total value of assets held by the fund.'), + trailing_pe: numOrNull.describe('The trailing twelve month P/E ratio.'), + dividend_yield: numOrNull.describe('The dividend yield of the fund, as a normalized percent.'), + dividend_rate_ttm: numOrNull.describe('The trailing twelve month annual dividend rate.'), + dividend_yield_ttm: numOrNull.describe('The trailing twelve month annual dividend yield.'), + year_high: numOrNull.describe('The fifty-two week high price.'), + year_low: numOrNull.describe('The fifty-two week low price.'), + ma_50d: numOrNull.describe('50-day moving average price.'), + ma_200d: numOrNull.describe('200-day moving average price.'), + return_ytd: numOrNull.describe('The year-to-date return, as a normalized percent.'), + return_3y_avg: numOrNull.describe('The three year average return, as a normalized percent.'), + return_5y_avg: numOrNull.describe('The five year average return, as a normalized percent.'), + beta_3y_avg: numOrNull.describe('The three year average beta.'), + volume_avg: numOrNull.describe('The average daily trading volume.'), + volume_avg_10d: numOrNull.describe('The average daily trading volume over the past ten days.'), + bid: numOrNull.describe('The current bid price.'), + bid_size: numOrNull.describe('The current bid size.'), + ask: numOrNull.describe('The current ask price.'), + ask_size: numOrNull.describe('The current ask size.'), + open: numOrNull.describe('The open price of the most recent trading session.'), + high: numOrNull.describe('The highest price of the most recent trading session.'), + low: numOrNull.describe('The lowest price of the most recent trading session.'), + volume: numOrNull.describe('The trading volume of the most recent trading session.'), + prev_close: numOrNull.describe('The previous closing price.'), +}).passthrough() +export type YFinanceEtfInfoData = z.infer + +const ETF_MODULES = [ + 'defaultKeyStatistics', + 'summaryDetail', + 'summaryProfile', + 'financialData', + 'price', + 'fundProfile', +] + +export class YFinanceEtfInfoFetcher extends Fetcher { + static override transformQuery(params: Record): YFinanceEtfInfoQueryParams { + return YFinanceEtfInfoQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceEtfInfoQueryParams, + credentials: Record | null, + ): Promise[]> { + const symbols = query.symbol.split(',').map(s => s.trim()).filter(Boolean) + const results: Record[] = [] + + const settled = await Promise.allSettled( + symbols.map(async (sym) => { + const data = await getQuoteSummary(sym, ETF_MODULES) + return { ...data, symbol: sym } + }) + ) + + for (const r of settled) { + if (r.status === 'fulfilled' && r.value) { + results.push(r.value) + } + } + + if (!results.length) { + throw new EmptyDataError('No ETF info data returned') + } + + return results + } + + static override transformData( + _query: YFinanceEtfInfoQueryParams, + data: Record[], + ): YFinanceEtfInfoData[] { + return data.map(d => { + // Handle inception date conversion + if (d.fundInceptionDate != null) { + const v = d.fundInceptionDate + if (v instanceof Date) { + d.fundInceptionDate = v.toISOString().slice(0, 10) + } else if (typeof v === 'number') { + d.fundInceptionDate = new Date(v * 1000).toISOString().slice(0, 10) + } + } + // Fallback to firstTradeDateEpochUtc if no inception date + if (!d.fundInceptionDate && d.firstTradeDateEpochUtc != null) { + const ts = d.firstTradeDateEpochUtc as number + d.fundInceptionDate = new Date(ts * 1000).toISOString().slice(0, 10) + } + + const aliased = applyAliases(d, ALIAS_DICT) + return YFinanceEtfInfoDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/futures-curve.ts b/packages/opentypebb/src/providers/yfinance/models/futures-curve.ts new file mode 100644 index 00000000..06c8bf7b --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/futures-curve.ts @@ -0,0 +1,168 @@ +/** + * Yahoo Finance Futures Curve Model. + * Maps to: openbb_yfinance/models/futures_curve.py + * + * Uses Yahoo Finance's futuresChain API to get the list of active futures symbols, + * then fetches current quotes for each. Falls back to manual symbol construction + * with an exchange mapping if the chain API is unavailable. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { FuturesCurveQueryParamsSchema, FuturesCurveDataSchema } from '../../../standard-models/futures-curve.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getFuturesSymbols, getHistoricalData, getQuoteSummary } from '../utils/helpers.js' +import { MONTHS } from '../utils/references.js' + +export const YFinanceFuturesCurveQueryParamsSchema = FuturesCurveQueryParamsSchema +export type YFinanceFuturesCurveQueryParams = z.infer + +export const YFinanceFuturesCurveDataSchema = FuturesCurveDataSchema +export type YFinanceFuturesCurveData = z.infer + +/** Reverse map: futures month letter → month number string */ +const MONTH_MAP: Record = { + F: '01', G: '02', H: '03', J: '04', K: '05', M: '06', + N: '07', Q: '08', U: '09', V: '10', X: '11', Z: '12', +} + +/** Extract expiration year-month from a futures ticker like CLF26.NYM → 2026-01 */ +function getExpirationMonth(symbol: string): string { + const base = symbol.split('.')[0] + if (base.length < 3) return '' + const monthLetter = base[base.length - 3] + const yearStr = base.slice(-2) + const month = MONTH_MAP[monthLetter] + if (!month) return '' + return `20${yearStr}-${month}` +} + +/** Map of common futures symbols to their Yahoo Finance exchange suffix */ +const EXCHANGE_MAP: Record = { + CL: 'NYM', // Crude Oil → NYMEX + NG: 'NYM', // Natural Gas → NYMEX + HO: 'NYM', // Heating Oil → NYMEX + RB: 'NYM', // RBOB Gasoline → NYMEX + PL: 'NYM', // Platinum → NYMEX + PA: 'NYM', // Palladium → NYMEX + GC: 'CMX', // Gold → COMEX + SI: 'CMX', // Silver → COMEX + HG: 'CMX', // Copper → COMEX + ES: 'CME', // E-mini S&P 500 → CME + NQ: 'CME', // E-mini Nasdaq 100 → CME + RTY: 'CME', // E-mini Russell 2000 → CME + YM: 'CBT', // E-mini Dow → CBOT + LE: 'CME', // Live Cattle → CME + HE: 'CME', // Lean Hogs → CME + GF: 'CME', // Feeder Cattle → CME + ZB: 'CBT', // T-Bond → CBOT + ZN: 'CBT', // 10-Yr Note → CBOT + ZF: 'CBT', // 5-Yr Note → CBOT + ZT: 'CBT', // 2-Yr Note → CBOT + ZC: 'CBT', // Corn → CBOT + ZS: 'CBT', // Soybeans → CBOT + ZW: 'CBT', // Wheat → CBOT + ZM: 'CBT', // Soybean Meal → CBOT + ZL: 'CBT', // Soybean Oil → CBOT + ZO: 'CBT', // Oats → CBOT + KC: 'NYB', // Coffee → NYBOT/ICE + CT: 'NYB', // Cotton → NYBOT/ICE + SB: 'NYB', // Sugar → NYBOT/ICE + CC: 'NYB', // Cocoa → NYBOT/ICE + OJ: 'NYB', // Orange Juice → NYBOT/ICE +} + +/** Generate manual futures symbols for next N months */ +function generateFuturesSymbols(baseSymbol: string, exchange: string, numMonths = 36): string[] { + const symbols: string[] = [] + const now = new Date() + const currentYear = now.getFullYear() + const currentMonth = now.getMonth() + 1 + + for (let i = 0; i < numMonths; i++) { + const month = ((currentMonth - 1 + i) % 12) + 1 + const yearOffset = Math.floor((currentMonth - 1 + i) / 12) + const year = (currentYear + yearOffset) % 100 + const monthCode = MONTHS[month] + if (monthCode) { + const yearStr = year.toString().padStart(2, '0') + symbols.push(`${baseSymbol}${monthCode}${yearStr}.${exchange}`) + } + } + + return symbols +} + +export class YFinanceFuturesCurveFetcher extends Fetcher { + static override transformQuery(params: Record): YFinanceFuturesCurveQueryParams { + return YFinanceFuturesCurveQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceFuturesCurveQueryParams, + _credentials: Record | null, + ): Promise[]> { + const baseSymbol = query.symbol.replace(/=F$/i, '').toUpperCase() + + // Step 1: Try to get the futures chain from Yahoo's API (like Python's get_futures_symbols) + let chainSymbols = await getFuturesSymbols(baseSymbol) + + // Step 2: If no chain from API, manually construct symbols with exchange mapping + if (!chainSymbols.length) { + const exchange = EXCHANGE_MAP[baseSymbol] ?? 'CME' + chainSymbols = generateFuturesSymbols(baseSymbol, exchange) + } + + // Step 3: Fetch current price for each symbol + const today = new Date().toISOString().slice(0, 10) + const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) + + const results = await Promise.allSettled( + chainSymbols.map(async (sym) => { + try { + const data = await getHistoricalData(sym, { + startDate: weekAgo, + endDate: today, + interval: '1d', + }) + if (!data.length) return null + const last = data[data.length - 1] + const expiration = getExpirationMonth(sym) + if (!expiration) return null + return { + expiration, + price: last.close ?? last.open ?? null, + } + } catch { + return null + } + }) + ) + + const curve: Record[] = [] + let consecutiveEmpty = 0 + for (const r of results) { + if (r.status === 'fulfilled' && r.value) { + curve.push(r.value) + consecutiveEmpty = 0 + } else { + consecutiveEmpty++ + // Stop after 12 consecutive empty (matches Python behavior) + if (consecutiveEmpty >= 12 && curve.length > 0) break + } + } + + if (!curve.length) throw new EmptyDataError(`No futures curve data for ${query.symbol}`) + + // Sort by expiration + curve.sort((a, b) => String(a.expiration).localeCompare(String(b.expiration))) + return curve + } + + static override transformData( + _query: YFinanceFuturesCurveQueryParams, + data: Record[], + ): YFinanceFuturesCurveData[] { + return data.map(d => YFinanceFuturesCurveDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/futures-historical.ts b/packages/opentypebb/src/providers/yfinance/models/futures-historical.ts new file mode 100644 index 00000000..daa09532 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/futures-historical.ts @@ -0,0 +1,115 @@ +/** + * Yahoo Finance Futures Historical Price Model. + * Maps to: openbb_yfinance/models/futures_historical.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { FuturesHistoricalQueryParamsSchema, FuturesHistoricalDataSchema } from '../../../standard-models/futures-historical.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getHistoricalData } from '../utils/helpers.js' +import { INTERVALS_DICT, MONTHS } from '../utils/references.js' + +export const YFinanceFuturesHistoricalQueryParamsSchema = FuturesHistoricalQueryParamsSchema.extend({ + interval: z.enum(['1m', '2m', '5m', '15m', '30m', '60m', '90m', '1h', '1d', '5d', '1W', '1M', '1Q']).default('1d').describe('Data granularity.'), +}) +export type YFinanceFuturesHistoricalQueryParams = z.infer + +export const YFinanceFuturesHistoricalDataSchema = FuturesHistoricalDataSchema +export type YFinanceFuturesHistoricalData = z.infer + +/** + * Format futures symbols for Yahoo Finance. + * - If expiration is given and no "." in symbol, append month code + year + ".CME" (default exchange) + * - If no "." and no "=F", append "=F" suffix + * - Uppercase everything + */ +function formatFuturesSymbols( + symbols: string[], + expiration: string | null, +): string[] { + const newSymbols: string[] = [] + + for (const symbol of symbols) { + let sym = symbol + if (expiration) { + // Parse expiration "YYYY-MM" + const parts = expiration.split('-') + if (parts.length >= 2) { + const month = parseInt(parts[1], 10) + const year = parts[0].slice(-2) + const monthCode = MONTHS[month] ?? '' + if (monthCode && !sym.includes('.')) { + // Append month code + year (no exchange lookup — simplified from Python) + sym = `${sym}${monthCode}${year}=F` + } + } + } + + // Ensure proper suffix + const upper = sym.toUpperCase() + if (!upper.includes('.') && !upper.includes('=F')) { + newSymbols.push(`${upper}=F`) + } else { + newSymbols.push(upper) + } + } + + return newSymbols +} + +export class YFinanceFuturesHistoricalFetcher extends Fetcher { + static override transformQuery(params: Record): YFinanceFuturesHistoricalQueryParams { + const now = new Date() + if (!params.start_date) { + const oneYearAgo = new Date(now) + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) + params.start_date = oneYearAgo.toISOString().slice(0, 10) + } + if (!params.end_date) { + params.end_date = now.toISOString().slice(0, 10) + } + + // Format symbols + const rawSymbols = String(params.symbol ?? '').split(',').map(s => s.trim()).filter(Boolean) + const expiration = params.expiration ? String(params.expiration) : null + const formatted = formatFuturesSymbols(rawSymbols, expiration) + params.symbol = formatted.join(',') + + return YFinanceFuturesHistoricalQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceFuturesHistoricalQueryParams, + credentials: Record | null, + ): Promise[]> { + const symbols = query.symbol.split(',').map(s => s.trim()).filter(Boolean) + const interval = INTERVALS_DICT[query.interval] ?? '1d' + + const allData: Record[] = [] + const results = await Promise.allSettled( + symbols.map(async (sym) => { + const data = await getHistoricalData(sym, { + startDate: query.start_date, + endDate: query.end_date, + interval, + }) + return data.map(d => ({ ...d, symbol: sym })) + }) + ) + + for (const r of results) { + if (r.status === 'fulfilled') allData.push(...r.value) + } + + if (!allData.length) throw new EmptyDataError('No futures historical data returned') + return allData + } + + static override transformData( + query: YFinanceFuturesHistoricalQueryParams, + data: Record[], + ): YFinanceFuturesHistoricalData[] { + return data.map(d => YFinanceFuturesHistoricalDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/gainers.ts b/packages/opentypebb/src/providers/yfinance/models/gainers.ts new file mode 100644 index 00000000..c6639da8 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/gainers.ts @@ -0,0 +1,52 @@ +/** + * Yahoo Finance Top Gainers Model. + * Maps to: openbb_yfinance/models/gainers.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityPerformanceQueryParamsSchema } from '../../../standard-models/equity-performance.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getPredefinedScreener } from '../utils/helpers.js' +import { YFPredefinedScreenerDataSchema, YF_SCREENER_ALIAS_DICT } from '../utils/references.js' + +export const YFGainersQueryParamsSchema = EquityPerformanceQueryParamsSchema.extend({ + limit: z.number().nullable().default(200).describe('Limit the number of results.'), +}) +export type YFGainersQueryParams = z.infer + +export const YFGainersDataSchema = YFPredefinedScreenerDataSchema +export type YFGainersData = z.infer + +export class YFGainersFetcher extends Fetcher { + static requireCredentials = false + + static override transformQuery(params: Record): YFGainersQueryParams { + return YFGainersQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFGainersQueryParams, + credentials: Record | null, + ): Promise[]> { + const results = await getPredefinedScreener('day_gainers', query.limit ?? 200) + return results + } + + static override transformData( + query: YFGainersQueryParams, + data: Record[], + ): YFGainersData[] { + const sorted = [...data].sort((a, b) => { + const diff = Number(b.regularMarketChangePercent ?? 0) - Number(a.regularMarketChangePercent ?? 0) + return query.sort === 'desc' ? diff : -diff + }) + return sorted.map(d => { + const aliased = applyAliases(d, YF_SCREENER_ALIAS_DICT) + if (typeof aliased.percent_change === 'number') { + aliased.percent_change = aliased.percent_change / 100 + } + return YFGainersDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/growth-tech.ts b/packages/opentypebb/src/providers/yfinance/models/growth-tech.ts new file mode 100644 index 00000000..ce26d07c --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/growth-tech.ts @@ -0,0 +1,51 @@ +/** + * Yahoo Finance Growth Technology Equities Model. + * Maps to: openbb_yfinance/models/growth_tech_equities.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityPerformanceQueryParamsSchema } from '../../../standard-models/equity-performance.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getPredefinedScreener } from '../utils/helpers.js' +import { YFPredefinedScreenerDataSchema, YF_SCREENER_ALIAS_DICT } from '../utils/references.js' + +export const YFGrowthTechQueryParamsSchema = EquityPerformanceQueryParamsSchema.extend({ + limit: z.number().nullable().default(200).describe('Limit the number of results.'), +}) +export type YFGrowthTechQueryParams = z.infer + +export const YFGrowthTechDataSchema = YFPredefinedScreenerDataSchema +export type YFGrowthTechData = z.infer + +export class YFGrowthTechEquitiesFetcher extends Fetcher { + static requireCredentials = false + + static override transformQuery(params: Record): YFGrowthTechQueryParams { + return YFGrowthTechQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFGrowthTechQueryParams, + credentials: Record | null, + ): Promise[]> { + return getPredefinedScreener('growth_technology_stocks', query.limit ?? 200) + } + + static override transformData( + query: YFGrowthTechQueryParams, + data: Record[], + ): YFGrowthTechData[] { + const sorted = [...data].sort((a, b) => { + const diff = Number(b.regularMarketChangePercent ?? 0) - Number(a.regularMarketChangePercent ?? 0) + return query.sort === 'desc' ? diff : -diff + }) + return sorted.map(d => { + const aliased = applyAliases(d, YF_SCREENER_ALIAS_DICT) + if (typeof aliased.percent_change === 'number') { + aliased.percent_change = aliased.percent_change / 100 + } + return YFGrowthTechDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/historical-dividends.ts b/packages/opentypebb/src/providers/yfinance/models/historical-dividends.ts new file mode 100644 index 00000000..6571b648 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/historical-dividends.ts @@ -0,0 +1,41 @@ +/** + * YFinance Historical Dividends Model. + * Maps to: openbb_yfinance/models/historical_dividends.py + * + * All data is split-adjusted. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { HistoricalDividendsQueryParamsSchema, HistoricalDividendsDataSchema } from '../../../standard-models/historical-dividends.js' +import { getHistoricalDividends } from '../utils/helpers.js' + +export const YFinanceHistoricalDividendsQueryParamsSchema = HistoricalDividendsQueryParamsSchema +export type YFinanceHistoricalDividendsQueryParams = z.infer + +export const YFinanceHistoricalDividendsDataSchema = HistoricalDividendsDataSchema +export type YFinanceHistoricalDividendsData = z.infer + +export class YFinanceHistoricalDividendsFetcher extends Fetcher { + static override transformQuery(params: Record): YFinanceHistoricalDividendsQueryParams { + return YFinanceHistoricalDividendsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceHistoricalDividendsQueryParams, + credentials: Record | null, + ): Promise[]> { + return getHistoricalDividends( + query.symbol, + query.start_date, + query.end_date, + ) + } + + static override transformData( + _query: YFinanceHistoricalDividendsQueryParams, + data: Record[], + ): YFinanceHistoricalDividendsData[] { + return data.map(d => YFinanceHistoricalDividendsDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/income-statement.ts b/packages/opentypebb/src/providers/yfinance/models/income-statement.ts new file mode 100644 index 00000000..282b8da6 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/income-statement.ts @@ -0,0 +1,62 @@ +/** + * YFinance Income Statement Model. + * Maps to: openbb_yfinance/models/income_statement.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { IncomeStatementQueryParamsSchema, IncomeStatementDataSchema } from '../../../standard-models/income-statement.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getFinancialStatements } from '../utils/helpers.js' + +// --- Query Params --- + +export const YFinanceIncomeStatementQueryParamsSchema = IncomeStatementQueryParamsSchema.extend({ + period: z.enum(['annual', 'quarter']).default('annual').describe('Time period of the data to return.'), + limit: z.coerce.number().int().min(1).max(5).nullable().default(5).describe('The number of data entries to return (max 5).'), +}) + +export type YFinanceIncomeStatementQueryParams = z.infer + +// --- Data --- + +const ALIAS_DICT: Record = { + selling_general_and_admin_expense: 'selling_general_and_administration', + research_and_development_expense: 'research_and_development', + total_pre_tax_income: 'pretax_income', + net_income_attributable_to_common_shareholders: 'net_income_common_stockholders', + weighted_average_basic_shares_outstanding: 'basic_average_shares', + weighted_average_diluted_shares_outstanding: 'diluted_average_shares', + basic_earnings_per_share: 'basic_e_p_s', + diluted_earnings_per_share: 'diluted_e_p_s', +} + +export const YFinanceIncomeStatementDataSchema = IncomeStatementDataSchema.extend({}).passthrough() +export type YFinanceIncomeStatementData = z.infer + +// --- Fetcher --- + +export class YFinanceIncomeStatementFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): YFinanceIncomeStatementQueryParams { + return YFinanceIncomeStatementQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceIncomeStatementQueryParams, + credentials: Record | null, + ): Promise[]> { + return getFinancialStatements(query.symbol, query.period, query.limit ?? 5) + } + + static override transformData( + query: YFinanceIncomeStatementQueryParams, + data: Record[], + ): YFinanceIncomeStatementData[] { + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return YFinanceIncomeStatementDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/index-historical.ts b/packages/opentypebb/src/providers/yfinance/models/index-historical.ts new file mode 100644 index 00000000..9d7a4a6b --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/index-historical.ts @@ -0,0 +1,126 @@ +/** + * Yahoo Finance Index Historical Model. + * Maps to: openbb_yfinance/models/index_historical.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { IndexHistoricalQueryParamsSchema, IndexHistoricalDataSchema } from '../../../standard-models/index-historical.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getHistoricalData } from '../utils/helpers.js' +import { INTERVALS_DICT, INDICES } from '../utils/references.js' + +export const YFinanceIndexHistoricalQueryParamsSchema = IndexHistoricalQueryParamsSchema.extend({ + interval: z.enum(['1m', '2m', '5m', '15m', '30m', '60m', '90m', '1h', '1d', '5d', '1W', '1M', '1Q']).default('1d').describe('Data granularity.'), +}) +export type YFinanceIndexHistoricalQueryParams = z.infer + +export const YFinanceIndexHistoricalDataSchema = IndexHistoricalDataSchema +export type YFinanceIndexHistoricalData = z.infer + +/** + * Resolve a user-supplied index code/name/symbol to a Yahoo Finance ticker. + * Checks in order: code match, name match, ^SYMBOL match, raw SYMBOL match. + */ +function resolveIndexSymbol(input: string): string | null { + const lower = input.toLowerCase() + const upper = input.toUpperCase() + + // Check by code (e.g. "sp500" → "^GSPC") + if (INDICES[lower]) { + return INDICES[lower].ticker + } + + // Check by name (title case, e.g. "S&P 500 Index") + const titleCase = input.charAt(0).toUpperCase() + input.slice(1).toLowerCase() + for (const entry of Object.values(INDICES)) { + if (entry.name === titleCase) { + return entry.ticker + } + } + + // Check if ^SYMBOL is a known ticker + const caretSymbol = '^' + upper + for (const entry of Object.values(INDICES)) { + if (entry.ticker === caretSymbol) { + return caretSymbol + } + } + + // Check if SYMBOL itself is a known ticker + for (const entry of Object.values(INDICES)) { + if (entry.ticker === upper) { + return upper + } + } + + return null +} + +export class YFinanceIndexHistoricalFetcher extends Fetcher { + static override transformQuery(params: Record): YFinanceIndexHistoricalQueryParams { + const now = new Date() + if (!params.start_date) { + const oneYearAgo = new Date(now) + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) + params.start_date = oneYearAgo.toISOString().slice(0, 10) + } + if (!params.end_date) { + params.end_date = now.toISOString().slice(0, 10) + } + + // Resolve index symbols + const rawSymbols = String(params.symbol ?? '').split(',').map(s => s.trim()).filter(Boolean) + const resolvedSymbols: string[] = [] + for (const sym of rawSymbols) { + const resolved = resolveIndexSymbol(sym) + if (resolved) { + resolvedSymbols.push(resolved) + } + // Skip unresolved symbols (matches Python's warn + skip behavior) + } + + if (resolvedSymbols.length === 0) { + // If none resolved, try using the raw symbols as-is (fallback) + params.symbol = rawSymbols.join(',') + } else { + params.symbol = resolvedSymbols.join(',') + } + + return YFinanceIndexHistoricalQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceIndexHistoricalQueryParams, + credentials: Record | null, + ): Promise[]> { + const symbols = query.symbol.split(',').map(s => s.trim()).filter(Boolean) + const interval = INTERVALS_DICT[query.interval] ?? '1d' + + const allData: Record[] = [] + const results = await Promise.allSettled( + symbols.map(async (sym) => { + const data = await getHistoricalData(sym, { + startDate: query.start_date, + endDate: query.end_date, + interval, + }) + return data.map(d => ({ ...d, symbol: sym })) + }) + ) + + for (const r of results) { + if (r.status === 'fulfilled') allData.push(...r.value) + } + + if (!allData.length) throw new EmptyDataError('No index historical data returned') + return allData + } + + static override transformData( + query: YFinanceIndexHistoricalQueryParams, + data: Record[], + ): YFinanceIndexHistoricalData[] { + return data.map(d => YFinanceIndexHistoricalDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/key-executives.ts b/packages/opentypebb/src/providers/yfinance/models/key-executives.ts new file mode 100644 index 00000000..92d91ae9 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/key-executives.ts @@ -0,0 +1,72 @@ +/** + * YFinance Key Executives Model. + * Maps to: openbb_yfinance/models/key_executives.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { KeyExecutivesQueryParamsSchema, KeyExecutivesDataSchema } from '../../../standard-models/key-executives.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getRawQuoteSummary } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + year_born: 'yearBorn', + fiscal_year: 'fiscalYear', + pay: 'totalPay', + exercised_value: 'exercisedValue', + unexercised_value: 'unexercisedValue', +} + +export const YFinanceKeyExecutivesQueryParamsSchema = KeyExecutivesQueryParamsSchema +export type YFinanceKeyExecutivesQueryParams = z.infer + +export const YFinanceKeyExecutivesDataSchema = KeyExecutivesDataSchema.extend({ + exercised_value: z.number().nullable().default(null).describe('Value of shares exercised.'), + unexercised_value: z.number().nullable().default(null).describe('Value of shares not exercised.'), + fiscal_year: z.number().nullable().default(null).describe('Fiscal year of the pay.'), +}).passthrough() +export type YFinanceKeyExecutivesData = z.infer + +export class YFinanceKeyExecutivesFetcher extends Fetcher { + static override transformQuery(params: Record): YFinanceKeyExecutivesQueryParams { + return YFinanceKeyExecutivesQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceKeyExecutivesQueryParams, + credentials: Record | null, + ): Promise[]> { + // Need raw (unflattened) quoteSummary to access companyOfficers array + const raw = await getRawQuoteSummary(query.symbol, ['assetProfile']) + const profile = (raw as any).assetProfile + if (!profile?.companyOfficers?.length) { + throw new EmptyDataError(`No executive data found for ${query.symbol}`) + } + + // Remove maxAge from each officer entry (matches Python) + const officers: Record[] = profile.companyOfficers.map((d: any) => { + const copy = { ...d } + delete copy.maxAge + // Handle nested raw values (yahoo-finance2 sometimes wraps in { raw, fmt }) + for (const [k, v] of Object.entries(copy)) { + if (v && typeof v === 'object' && 'raw' in (v as any)) { + copy[k] = (v as any).raw + } + } + return copy + }) + + return officers + } + + static override transformData( + _query: YFinanceKeyExecutivesQueryParams, + data: Record[], + ): YFinanceKeyExecutivesData[] { + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + return YFinanceKeyExecutivesDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/key-metrics.ts b/packages/opentypebb/src/providers/yfinance/models/key-metrics.ts new file mode 100644 index 00000000..87dfea62 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/key-metrics.ts @@ -0,0 +1,128 @@ +/** + * YFinance Key Metrics Model. + * Maps to: openbb_yfinance/models/key_metrics.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { KeyMetricsQueryParamsSchema, KeyMetricsDataSchema } from '../../../standard-models/key-metrics.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getQuoteSummary } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + market_cap: 'marketCap', + pe_ratio: 'trailingPE', + forward_pe: 'forwardPE', + peg_ratio: 'pegRatio', + peg_ratio_ttm: 'trailingPegRatio', + eps_ttm: 'trailingEps', + eps_forward: 'forwardEps', + enterprise_to_ebitda: 'enterpriseToEbitda', + earnings_growth: 'earningsGrowth', + earnings_growth_quarterly: 'earningsQuarterlyGrowth', + revenue_per_share: 'revenuePerShare', + revenue_growth: 'revenueGrowth', + enterprise_to_revenue: 'enterpriseToRevenue', + cash_per_share: 'totalCashPerShare', + quick_ratio: 'quickRatio', + current_ratio: 'currentRatio', + debt_to_equity: 'debtToEquity', + gross_margin: 'grossMargins', + operating_margin: 'operatingMargins', + ebitda_margin: 'ebitdaMargins', + profit_margin: 'profitMargins', + return_on_assets: 'returnOnAssets', + return_on_equity: 'returnOnEquity', + dividend_yield: 'dividendYield', + dividend_yield_5y_avg: 'fiveYearAvgDividendYield', + payout_ratio: 'payoutRatio', + book_value: 'bookValue', + price_to_book: 'priceToBook', + enterprise_value: 'enterpriseValue', + overall_risk: 'overallRisk', + audit_risk: 'auditRisk', + board_risk: 'boardRisk', + compensation_risk: 'compensationRisk', + shareholder_rights_risk: 'shareHolderRightsRisk', + price_return_1y: '52WeekChange', + currency: 'financialCurrency', +} + +export const YFinanceKeyMetricsQueryParamsSchema = KeyMetricsQueryParamsSchema +export type YFinanceKeyMetricsQueryParams = z.infer + +export const YFinanceKeyMetricsDataSchema = KeyMetricsDataSchema.extend({ + pe_ratio: z.number().nullable().default(null).describe('Price-to-earnings ratio (TTM).'), + forward_pe: z.number().nullable().default(null).describe('Forward price-to-earnings ratio.'), + peg_ratio: z.number().nullable().default(null).describe('PEG ratio (5-year expected).'), + peg_ratio_ttm: z.number().nullable().default(null).describe('PEG ratio (TTM).'), + eps_ttm: z.number().nullable().default(null).describe('Earnings per share (TTM).'), + eps_forward: z.number().nullable().default(null).describe('Forward earnings per share.'), + enterprise_to_ebitda: z.number().nullable().default(null).describe('Enterprise value to EBITDA ratio.'), + earnings_growth: z.number().nullable().default(null).describe('Earnings growth (YoY).'), + earnings_growth_quarterly: z.number().nullable().default(null).describe('Quarterly earnings growth (YoY).'), + revenue_per_share: z.number().nullable().default(null).describe('Revenue per share (TTM).'), + revenue_growth: z.number().nullable().default(null).describe('Revenue growth (YoY).'), + enterprise_to_revenue: z.number().nullable().default(null).describe('Enterprise value to revenue ratio.'), + cash_per_share: z.number().nullable().default(null).describe('Cash per share.'), + quick_ratio: z.number().nullable().default(null).describe('Quick ratio.'), + current_ratio: z.number().nullable().default(null).describe('Current ratio.'), + debt_to_equity: z.number().nullable().default(null).describe('Debt-to-equity ratio.'), + gross_margin: z.number().nullable().default(null).describe('Gross margin.'), + operating_margin: z.number().nullable().default(null).describe('Operating margin.'), + ebitda_margin: z.number().nullable().default(null).describe('EBITDA margin.'), + profit_margin: z.number().nullable().default(null).describe('Profit margin.'), + return_on_assets: z.number().nullable().default(null).describe('Return on assets.'), + return_on_equity: z.number().nullable().default(null).describe('Return on equity.'), + dividend_yield: z.number().nullable().default(null).describe('Dividend yield.'), + dividend_yield_5y_avg: z.number().nullable().default(null).describe('5-year average dividend yield.'), + payout_ratio: z.number().nullable().default(null).describe('Payout ratio.'), + book_value: z.number().nullable().default(null).describe('Book value per share.'), + price_to_book: z.number().nullable().default(null).describe('Price-to-book ratio.'), + enterprise_value: z.number().nullable().default(null).describe('Enterprise value.'), + overall_risk: z.number().nullable().default(null).describe('Overall risk score.'), + audit_risk: z.number().nullable().default(null).describe('Audit risk score.'), + board_risk: z.number().nullable().default(null).describe('Board risk score.'), + compensation_risk: z.number().nullable().default(null).describe('Compensation risk score.'), + shareholder_rights_risk: z.number().nullable().default(null).describe('Shareholder rights risk score.'), + beta: z.number().nullable().default(null).describe('Beta relative to the broad market.'), + price_return_1y: z.number().nullable().default(null).describe('One-year price return.'), +}).passthrough() +export type YFinanceKeyMetricsData = z.infer + +export class YFinanceKeyMetricsFetcher extends Fetcher { + static override transformQuery(params: Record): YFinanceKeyMetricsQueryParams { + return YFinanceKeyMetricsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceKeyMetricsQueryParams, + credentials: Record | null, + ): Promise[]> { + const symbols = query.symbol.split(',').map(s => s.trim()).filter(Boolean) + const results = await Promise.allSettled( + symbols.map(s => getQuoteSummary(s, ['defaultKeyStatistics', 'summaryDetail', 'financialData'])) + ) + const data: Record[] = [] + for (const r of results) { + if (r.status === 'fulfilled' && r.value) data.push(r.value) + } + return data + } + + static override transformData( + query: YFinanceKeyMetricsQueryParams, + data: Record[], + ): YFinanceKeyMetricsData[] { + if (!data.length) throw new EmptyDataError('No key metrics data returned') + return data.map(d => { + const aliased = applyAliases(d, ALIAS_DICT) + // Normalize 5y avg dividend yield (comes as whole number, not decimal) + if (typeof aliased.dividend_yield_5y_avg === 'number') { + aliased.dividend_yield_5y_avg = aliased.dividend_yield_5y_avg / 100 + } + return YFinanceKeyMetricsDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/losers.ts b/packages/opentypebb/src/providers/yfinance/models/losers.ts new file mode 100644 index 00000000..acc8ee7d --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/losers.ts @@ -0,0 +1,51 @@ +/** + * Yahoo Finance Top Losers Model. + * Maps to: openbb_yfinance/models/losers.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityPerformanceQueryParamsSchema } from '../../../standard-models/equity-performance.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getPredefinedScreener } from '../utils/helpers.js' +import { YFPredefinedScreenerDataSchema, YF_SCREENER_ALIAS_DICT } from '../utils/references.js' + +export const YFLosersQueryParamsSchema = EquityPerformanceQueryParamsSchema.extend({ + limit: z.number().nullable().default(200).describe('Limit the number of results.'), +}) +export type YFLosersQueryParams = z.infer + +export const YFLosersDataSchema = YFPredefinedScreenerDataSchema +export type YFLosersData = z.infer + +export class YFLosersFetcher extends Fetcher { + static requireCredentials = false + + static override transformQuery(params: Record): YFLosersQueryParams { + return YFLosersQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFLosersQueryParams, + credentials: Record | null, + ): Promise[]> { + return getPredefinedScreener('day_losers', query.limit ?? 200) + } + + static override transformData( + query: YFLosersQueryParams, + data: Record[], + ): YFLosersData[] { + const sorted = [...data].sort((a, b) => { + const diff = Number(b.regularMarketChangePercent ?? 0) - Number(a.regularMarketChangePercent ?? 0) + return query.sort === 'desc' ? diff : -diff + }) + return sorted.map(d => { + const aliased = applyAliases(d, YF_SCREENER_ALIAS_DICT) + if (typeof aliased.percent_change === 'number') { + aliased.percent_change = aliased.percent_change / 100 + } + return YFLosersDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/options-chains.ts b/packages/opentypebb/src/providers/yfinance/models/options-chains.ts new file mode 100644 index 00000000..9f54395f --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/options-chains.ts @@ -0,0 +1,230 @@ +/** + * Yahoo Finance Options Chains Model. + * Maps to: openbb_yfinance/models/options_chains.py + * + * Fetches full options chain for a given symbol using yahoo-finance2's options API, + * with fallback to direct Yahoo Finance HTTP endpoint. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { OptionsChainsQueryParamsSchema, OptionsChainsDataSchema } from '../../../standard-models/options-chains.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getOptionsData } from '../utils/helpers.js' + +export const YFinanceOptionsChainsQueryParamsSchema = OptionsChainsQueryParamsSchema +export type YFinanceOptionsChainsQueryParams = z.infer + +export const YFinanceOptionsChainsDataSchema = OptionsChainsDataSchema +export type YFinanceOptionsChainsData = z.infer + +/** Fetch options chain using yahoo-finance2's options() API via shared singleton */ +async function fetchViaYF2(symbol: string): Promise[]> { + // Step 1: Get first expiration + list of all expirations + const optionsResult = await getOptionsData(symbol) + + const expirationDates: Date[] = optionsResult?.expirationDates ?? [] + if (!expirationDates.length) { + throw new EmptyDataError(`No options data found for ${symbol}`) + } + + const underlyingPrice = optionsResult?.quote?.regularMarketPrice ?? null + const today = new Date().toISOString().slice(0, 10) + const allContracts: Record[] = [] + + const processOptions = (options: any[], type: string, expirationStr: string) => { + for (const opt of options) { + const strike = opt.strike ?? 0 + const now = new Date() + const exp = new Date(expirationStr) + const dte = Math.max(0, Math.ceil((exp.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))) + + allContracts.push({ + underlying_symbol: symbol, + underlying_price: underlyingPrice, + contract_symbol: opt.contractSymbol ?? '', + eod_date: today, + expiration: expirationStr, + dte, + strike, + option_type: type, + open_interest: opt.openInterest ?? null, + volume: opt.volume ?? null, + last_trade_price: opt.lastPrice ?? null, + last_trade_time: opt.lastTradeDate + ? (opt.lastTradeDate instanceof Date ? opt.lastTradeDate.toISOString() : String(opt.lastTradeDate)) + : null, + bid: opt.bid ?? null, + ask: opt.ask ?? null, + mark: opt.bid != null && opt.ask != null ? (opt.bid + opt.ask) / 2 : null, + change: opt.change ?? null, + change_percent: opt.percentChange != null ? opt.percentChange / 100 : null, + implied_volatility: opt.impliedVolatility ?? null, + in_the_money: opt.inTheMoney ?? null, + currency: opt.currency ?? null, + }) + } + } + + // Process first expiration (already have data) + if (optionsResult.options?.[0]) { + const firstExpStr = expirationDates[0] instanceof Date + ? expirationDates[0].toISOString().slice(0, 10) + : String(expirationDates[0]).slice(0, 10) + const firstOpts = optionsResult.options[0] + processOptions(firstOpts.calls ?? [], 'call', firstExpStr) + processOptions(firstOpts.puts ?? [], 'put', firstExpStr) + } + + // Fetch remaining expirations in batches + const remainingDates = expirationDates.slice(1) + const batchSize = 5 + for (let i = 0; i < remainingDates.length; i += batchSize) { + const batch = remainingDates.slice(i, i + batchSize) + await Promise.allSettled( + batch.map(async (expDate) => { + const dateObj = expDate instanceof Date ? expDate : new Date(expDate) + const dateStr = dateObj.toISOString().slice(0, 10) + try { + const result = await getOptionsData(symbol, dateObj) + if (result?.options?.[0]) { + processOptions(result.options[0].calls ?? [], 'call', dateStr) + processOptions(result.options[0].puts ?? [], 'put', dateStr) + } + } catch { + // Skip failed expirations + } + }) + ) + } + + return allContracts +} + +/** Fetch options via direct Yahoo Finance v7 HTTP API (fallback) */ +async function fetchViaDirect(symbol: string): Promise[]> { + const baseUrl = `https://query2.finance.yahoo.com/v7/finance/options/${encodeURIComponent(symbol)}` + + // Get first expiration + list of all expirations + const resp = await fetch(baseUrl, { signal: AbortSignal.timeout(15000) }) + if (!resp.ok) throw new EmptyDataError(`Yahoo options API returned ${resp.status}`) + const json = await resp.json() as any + const result = json?.optionChain?.result?.[0] + if (!result) throw new EmptyDataError(`No options data for ${symbol}`) + + const expirationEpochs: number[] = result.expirationDates ?? [] + if (!expirationEpochs.length) throw new EmptyDataError(`No option expirations for ${symbol}`) + + const underlyingPrice = result.quote?.regularMarketPrice ?? null + const today = new Date().toISOString().slice(0, 10) + const allContracts: Record[] = [] + + const processChain = (options: any[], type: string, expirationStr: string) => { + for (const opt of options) { + const strike = opt.strike ?? 0 + const now = new Date() + const exp = new Date(expirationStr) + const dte = Math.max(0, Math.ceil((exp.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))) + + allContracts.push({ + underlying_symbol: symbol, + underlying_price: underlyingPrice, + contract_symbol: opt.contractSymbol ?? '', + eod_date: today, + expiration: expirationStr, + dte, + strike, + option_type: type, + open_interest: opt.openInterest ?? null, + volume: opt.volume ?? null, + last_trade_price: opt.lastPrice ?? null, + last_trade_time: opt.lastTradeDate + ? new Date(opt.lastTradeDate * 1000).toISOString() + : null, + bid: opt.bid ?? null, + ask: opt.ask ?? null, + mark: opt.bid != null && opt.ask != null ? (opt.bid + opt.ask) / 2 : null, + change: opt.change ?? null, + change_percent: opt.percentChange != null ? opt.percentChange / 100 : null, + implied_volatility: opt.impliedVolatility ?? null, + in_the_money: opt.inTheMoney ?? null, + currency: opt.currency ?? null, + }) + } + } + + // Process first expiration (already have data) + if (result.options?.[0]) { + const firstExpStr = new Date(expirationEpochs[0] * 1000).toISOString().slice(0, 10) + processChain(result.options[0].calls ?? [], 'call', firstExpStr) + processChain(result.options[0].puts ?? [], 'put', firstExpStr) + } + + // Fetch remaining expirations + const remaining = expirationEpochs.slice(1) + const batchSize = 5 + for (let i = 0; i < remaining.length; i += batchSize) { + const batch = remaining.slice(i, i + batchSize) + await Promise.allSettled( + batch.map(async (epoch) => { + const dateStr = new Date(epoch * 1000).toISOString().slice(0, 10) + try { + const r = await fetch(`${baseUrl}?date=${epoch}`, { signal: AbortSignal.timeout(15000) }) + if (!r.ok) return + const j = await r.json() as any + const chain = j?.optionChain?.result?.[0]?.options?.[0] + if (chain) { + processChain(chain.calls ?? [], 'call', dateStr) + processChain(chain.puts ?? [], 'put', dateStr) + } + } catch { + // Skip failed expirations + } + }) + ) + } + + return allContracts +} + +export class YFinanceOptionsChainsFetcher extends Fetcher { + static override transformQuery(params: Record): YFinanceOptionsChainsQueryParams { + return YFinanceOptionsChainsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceOptionsChainsQueryParams, + _credentials: Record | null, + ): Promise[]> { + let symbol = query.symbol.toUpperCase() + // Prefix index symbols with ^ (matching Python behavior) + if (['VIX', 'RUT', 'SPX', 'NDX'].includes(symbol)) { + symbol = '^' + symbol + } + + // Try yahoo-finance2 first, fall back to direct API + let contracts: Record[] + try { + contracts = await fetchViaYF2(symbol) + } catch { + try { + contracts = await fetchViaDirect(symbol) + } catch (err) { + throw new EmptyDataError(`Failed to fetch options for ${query.symbol}: ${err}`) + } + } + + if (!contracts.length) { + throw new EmptyDataError(`No options contracts found for ${query.symbol}`) + } + + return contracts + } + + static override transformData( + _query: YFinanceOptionsChainsQueryParams, + data: Record[], + ): YFinanceOptionsChainsData[] { + return data.map(d => OptionsChainsDataSchema.parse(d)) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/price-target-consensus.ts b/packages/opentypebb/src/providers/yfinance/models/price-target-consensus.ts new file mode 100644 index 00000000..a1f3d40d --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/price-target-consensus.ts @@ -0,0 +1,66 @@ +/** + * YFinance Price Target Consensus Model. + * Maps to: openbb_yfinance/models/price_target_consensus.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { PriceTargetConsensusQueryParamsSchema, PriceTargetConsensusDataSchema } from '../../../standard-models/price-target-consensus.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getQuoteSummary } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + target_high: 'targetHighPrice', + target_low: 'targetLowPrice', + target_consensus: 'targetMeanPrice', + target_median: 'targetMedianPrice', + recommendation: 'recommendationKey', + recommendation_mean: 'recommendationMean', + number_of_analysts: 'numberOfAnalystOpinions', + current_price: 'currentPrice', +} + +export const YFinancePriceTargetConsensusQueryParamsSchema = PriceTargetConsensusQueryParamsSchema +export type YFinancePriceTargetConsensusQueryParams = z.infer + +export const YFinancePriceTargetConsensusDataSchema = PriceTargetConsensusDataSchema.extend({ + recommendation: z.string().nullable().default(null).describe('Recommendation - buy, sell, etc.'), + recommendation_mean: z.number().nullable().default(null).describe('Mean recommendation score where 1 is strong buy and 5 is strong sell.'), + number_of_analysts: z.number().nullable().default(null).describe('Number of analysts providing opinions.'), + current_price: z.number().nullable().default(null).describe('Current price of the stock.'), + currency: z.string().nullable().default(null).describe('Currency the stock is priced in.'), +}).passthrough() +export type YFinancePriceTargetConsensusData = z.infer + +export class YFinancePriceTargetConsensusFetcher extends Fetcher { + static override transformQuery(params: Record): YFinancePriceTargetConsensusQueryParams { + if (!params.symbol) throw new Error('Symbol is a required field for yFinance.') + return YFinancePriceTargetConsensusQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinancePriceTargetConsensusQueryParams, + credentials: Record | null, + ): Promise[]> { + const symbols = (query.symbol ?? '').split(',').map(s => s.trim()).filter(Boolean) + const results = await Promise.allSettled( + symbols.map(s => getQuoteSummary(s, ['financialData'])) + ) + const data: Record[] = [] + for (const r of results) { + if (r.status === 'fulfilled' && r.value && r.value.numberOfAnalystOpinions != null) { + data.push(r.value) + } + } + return data + } + + static override transformData( + query: YFinancePriceTargetConsensusQueryParams, + data: Record[], + ): YFinancePriceTargetConsensusData[] { + if (!data.length) throw new EmptyDataError('No price target data returned') + return data.map(d => YFinancePriceTargetConsensusDataSchema.parse(applyAliases(d, ALIAS_DICT))) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/share-statistics.ts b/packages/opentypebb/src/providers/yfinance/models/share-statistics.ts new file mode 100644 index 00000000..74df48a7 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/share-statistics.ts @@ -0,0 +1,114 @@ +/** + * YFinance Share Statistics Model. + * Maps to: openbb_yfinance/models/share_statistics.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { ShareStatisticsQueryParamsSchema, ShareStatisticsDataSchema } from '../../../standard-models/share-statistics.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { getQuoteSummary } from '../utils/helpers.js' + +const ALIAS_DICT: Record = { + outstanding_shares: 'sharesOutstanding', + float_shares: 'floatShares', + date: 'dateShortInterest', + implied_shares_outstanding: 'impliedSharesOutstanding', + short_interest: 'sharesShort', + short_percent_of_float: 'shortPercentOfFloat', + days_to_cover: 'shortRatio', + short_interest_prev_month: 'sharesShortPriorMonth', + short_interest_prev_date: 'sharesShortPreviousMonthDate', + insider_ownership: 'heldPercentInsiders', + institution_ownership: 'heldPercentInstitutions', + institution_float_ownership: 'institutionsFloatPercentHeld', + institutions_count: 'institutionsCount', +} + +const numOrNull = z.number().nullable().default(null) + +export const YFinanceShareStatisticsQueryParamsSchema = ShareStatisticsQueryParamsSchema +export type YFinanceShareStatisticsQueryParams = z.infer + +export const YFinanceShareStatisticsDataSchema = ShareStatisticsDataSchema.extend({ + implied_shares_outstanding: numOrNull.describe('Implied Shares Outstanding of common equity, assuming the conversion of all convertible subsidiary equity into common.'), + short_interest: numOrNull.describe('Number of shares that are reported short.'), + short_percent_of_float: numOrNull.describe('Percentage of shares that are reported short, as a normalized percent.'), + days_to_cover: numOrNull.describe('Number of days to repurchase the shares as a ratio of average daily volume.'), + short_interest_prev_month: numOrNull.describe('Number of shares that were reported short in the previous month.'), + short_interest_prev_date: z.string().nullable().default(null).describe('Date of the previous month short interest report.'), + insider_ownership: numOrNull.describe('Percentage of shares held by insiders, as a normalized percent.'), + institution_ownership: numOrNull.describe('Percentage of shares held by institutions, as a normalized percent.'), + institution_float_ownership: numOrNull.describe('Percentage of float held by institutions, as a normalized percent.'), + institutions_count: numOrNull.describe('Number of institutions holding shares.'), +}).passthrough() +export type YFinanceShareStatisticsData = z.infer + +export class YFinanceShareStatisticsFetcher extends Fetcher { + static override transformQuery(params: Record): YFinanceShareStatisticsQueryParams { + return YFinanceShareStatisticsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFinanceShareStatisticsQueryParams, + credentials: Record | null, + ): Promise[]> { + const symbols = query.symbol.split(',').map(s => s.trim()).filter(Boolean) + const results: Record[] = [] + + const settled = await Promise.allSettled( + symbols.map(async (symbol) => { + const data = await getQuoteSummary(symbol, [ + 'defaultKeyStatistics', + 'majorHoldersBreakdown', + ]) + return data + }), + ) + + for (const r of settled) { + if (r.status === 'fulfilled' && r.value) { + const data = r.value as Record + // Only include if we got shares outstanding + if (data.sharesOutstanding != null) { + results.push(data) + } + } + } + + if (!results.length) { + throw new EmptyDataError('No share statistics data returned') + } + + return results + } + + static override transformData( + _query: YFinanceShareStatisticsQueryParams, + data: Record[], + ): YFinanceShareStatisticsData[] { + return data.map(d => { + // Convert epoch timestamps for date fields + if (typeof d.dateShortInterest === 'number') { + d.dateShortInterest = new Date(d.dateShortInterest * 1000).toISOString().slice(0, 10) + } + if (typeof d.sharesShortPreviousMonthDate === 'number') { + d.sharesShortPreviousMonthDate = new Date(d.sharesShortPreviousMonthDate * 1000).toISOString().slice(0, 10) + } + + // yahoo-finance2 uses insidersPercentHeld / institutionsPercentHeld + // while yfinance Python uses heldPercentInsiders / heldPercentInstitutions + // Map yahoo-finance2 names to the alias dict expected names + if (d.insidersPercentHeld != null && d.heldPercentInsiders == null) { + d.heldPercentInsiders = d.insidersPercentHeld + } + if (d.institutionsPercentHeld != null && d.heldPercentInstitutions == null) { + d.heldPercentInstitutions = d.institutionsPercentHeld + } + + const aliased = applyAliases(d, ALIAS_DICT) + return YFinanceShareStatisticsDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/undervalued-growth.ts b/packages/opentypebb/src/providers/yfinance/models/undervalued-growth.ts new file mode 100644 index 00000000..a34281d8 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/undervalued-growth.ts @@ -0,0 +1,51 @@ +/** + * Yahoo Finance Undervalued Growth Equities Model. + * Maps to: openbb_yfinance/models/undervalued_growth_equities.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityPerformanceQueryParamsSchema } from '../../../standard-models/equity-performance.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getPredefinedScreener } from '../utils/helpers.js' +import { YFPredefinedScreenerDataSchema, YF_SCREENER_ALIAS_DICT } from '../utils/references.js' + +export const YFUndervaluedGrowthQueryParamsSchema = EquityPerformanceQueryParamsSchema.extend({ + limit: z.number().nullable().default(200).describe('Limit the number of results.'), +}) +export type YFUndervaluedGrowthQueryParams = z.infer + +export const YFUndervaluedGrowthDataSchema = YFPredefinedScreenerDataSchema +export type YFUndervaluedGrowthData = z.infer + +export class YFUndervaluedGrowthEquitiesFetcher extends Fetcher { + static requireCredentials = false + + static override transformQuery(params: Record): YFUndervaluedGrowthQueryParams { + return YFUndervaluedGrowthQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFUndervaluedGrowthQueryParams, + credentials: Record | null, + ): Promise[]> { + return getPredefinedScreener('undervalued_growth_stocks', query.limit ?? 200) + } + + static override transformData( + query: YFUndervaluedGrowthQueryParams, + data: Record[], + ): YFUndervaluedGrowthData[] { + const sorted = [...data].sort((a, b) => { + const diff = Number(b.regularMarketChangePercent ?? 0) - Number(a.regularMarketChangePercent ?? 0) + return query.sort === 'desc' ? diff : -diff + }) + return sorted.map(d => { + const aliased = applyAliases(d, YF_SCREENER_ALIAS_DICT) + if (typeof aliased.percent_change === 'number') { + aliased.percent_change = aliased.percent_change / 100 + } + return YFUndervaluedGrowthDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/models/undervalued-large-caps.ts b/packages/opentypebb/src/providers/yfinance/models/undervalued-large-caps.ts new file mode 100644 index 00000000..42326fd5 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/models/undervalued-large-caps.ts @@ -0,0 +1,51 @@ +/** + * Yahoo Finance Undervalued Large Caps Model. + * Maps to: openbb_yfinance/models/undervalued_large_caps.py + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { EquityPerformanceQueryParamsSchema } from '../../../standard-models/equity-performance.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' +import { getPredefinedScreener } from '../utils/helpers.js' +import { YFPredefinedScreenerDataSchema, YF_SCREENER_ALIAS_DICT } from '../utils/references.js' + +export const YFUndervaluedLargeCapsQueryParamsSchema = EquityPerformanceQueryParamsSchema.extend({ + limit: z.number().nullable().default(200).describe('Limit the number of results.'), +}) +export type YFUndervaluedLargeCapsQueryParams = z.infer + +export const YFUndervaluedLargeCapsDataSchema = YFPredefinedScreenerDataSchema +export type YFUndervaluedLargeCapsData = z.infer + +export class YFUndervaluedLargeCapsFetcher extends Fetcher { + static requireCredentials = false + + static override transformQuery(params: Record): YFUndervaluedLargeCapsQueryParams { + return YFUndervaluedLargeCapsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: YFUndervaluedLargeCapsQueryParams, + credentials: Record | null, + ): Promise[]> { + return getPredefinedScreener('undervalued_large_caps', query.limit ?? 200) + } + + static override transformData( + query: YFUndervaluedLargeCapsQueryParams, + data: Record[], + ): YFUndervaluedLargeCapsData[] { + const sorted = [...data].sort((a, b) => { + const diff = Number(b.regularMarketChangePercent ?? 0) - Number(a.regularMarketChangePercent ?? 0) + return query.sort === 'desc' ? diff : -diff + }) + return sorted.map(d => { + const aliased = applyAliases(d, YF_SCREENER_ALIAS_DICT) + if (typeof aliased.percent_change === 'number') { + aliased.percent_change = aliased.percent_change / 100 + } + return YFUndervaluedLargeCapsDataSchema.parse(aliased) + }) + } +} diff --git a/packages/opentypebb/src/providers/yfinance/utils/helpers.ts b/packages/opentypebb/src/providers/yfinance/utils/helpers.ts new file mode 100644 index 00000000..5dea220a --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/utils/helpers.ts @@ -0,0 +1,481 @@ +/** + * Yahoo Finance helpers module. + * Maps to: openbb_yfinance/utils/helpers.py + * + * Uses yahoo-finance2 npm package for authenticated access to Yahoo Finance API. + * The package handles cookie/crumb authentication automatically. + */ + +import YahooFinance from 'yahoo-finance2' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { SCREENER_FIELDS } from './references.js' + +// Singleton Yahoo Finance instance — reset on persistent failures +let _yf: InstanceType | null = null +let _yfFailCount = 0 +function getYF(): InstanceType { + if (!_yf || _yfFailCount >= 3) { + _yf = new YahooFinance({ suppressNotices: ['yahooSurvey'] }) + _yfFailCount = 0 + } + return _yf +} + +function recordYFSuccess(): void { _yfFailCount = 0 } +function recordYFFailure(): void { _yfFailCount++ } + +/** Retry a function up to maxRetries times with delay between attempts */ +async function withRetry(fn: () => Promise, maxRetries = 2, delayMs = 1000): Promise { + let lastError: unknown + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn() + } catch (err) { + lastError = err + if (attempt < maxRetries) { + await new Promise(r => setTimeout(r, delayMs * (attempt + 1))) + } + } + } + throw lastError +} + +/** + * Get data from Yahoo Finance predefined screener. + * Uses yahoo-finance2's screener() method with scrIds parameter. + * Maps to: get_custom_screener() in helpers.py + * + * @param scrId - Predefined screener ID: 'day_gainers', 'day_losers', 'most_actives', etc. + * @param count - Max results to return (default: 250) + */ +export async function getPredefinedScreener( + scrId: string, + count = 250, +): Promise[]> { + let result: any + + // Screener requires crumb authentication which can become stale in long-running + // server processes. On failure, reset the YF singleton to force a fresh crumb, + // then retry once. + for (let attempt = 0; attempt < 2; attempt++) { + const yf = getYF() + try { + result = await (yf as any).screener({ scrIds: scrId, count }) + recordYFSuccess() + break + } catch (err) { + recordYFFailure() + if (attempt === 0) { + // Force singleton reset for fresh crumb on retry + _yf = null + _yfFailCount = 0 + await new Promise(r => setTimeout(r, 1000)) + continue + } + throw err + } + } + + const quotes: any[] = result?.quotes ?? [] + if (!quotes.length) { + throw new EmptyDataError(`No data found for screener: ${scrId}`) + } + + // Normalize quotes + const output: Record[] = [] + for (const item of quotes) { + // Format earnings date if available + if (item.earningsTimestamp) { + try { + const ts = typeof item.earningsTimestamp === 'number' + ? item.earningsTimestamp + : item.earningsTimestamp instanceof Date + ? item.earningsTimestamp.getTime() / 1000 + : null + if (ts) { + item.earnings_date = new Date(ts * 1000).toISOString().replace('T', ' ').slice(0, 19) + } + } catch { + item.earnings_date = null + } + } + + const result: Record = {} + for (const k of SCREENER_FIELDS) { + result[k] = item[k] ?? null + } + + if (result.regularMarketChange != null && result.regularMarketVolume != null) { + output.push(result) + } + } + + return output +} + +/** @deprecated Use getPredefinedScreener instead */ +export const getCustomScreener = getPredefinedScreener as any + +/** + * Fetch quote summary data from Yahoo Finance for one symbol. + * Uses yahoo-finance2's quoteSummary which handles authentication. + * Maps to: yfinance Ticker.get_info() pattern. + */ +export async function getQuoteSummary( + symbol: string, + modules: string[] = ['defaultKeyStatistics', 'summaryDetail', 'summaryProfile', 'financialData', 'price'], +): Promise> { + const yf = getYF() + + let result: any + try { + result = await withRetry(() => yf.quoteSummary(symbol, { modules: modules as any })) + recordYFSuccess() + } catch (err) { + recordYFFailure() + throw err + } + + if (!result) { + throw new EmptyDataError(`No quote summary data for ${symbol}`) + } + + // Flatten all modules into a single dict + const flat: Record = { symbol } + for (const [_modName, mod] of Object.entries(result)) { + if (mod && typeof mod === 'object') { + for (const [key, value] of Object.entries(mod as Record)) { + if (value !== undefined && value !== null) { + if (value instanceof Date) { + flat[key] = value.toISOString().slice(0, 10) + } else if (typeof value !== 'object') { + flat[key] = value + } else if (typeof value === 'object' && value !== null && 'raw' in (value as any)) { + flat[key] = (value as any).raw + } + // Skip nested objects (companyOfficers, etc.) + } + } + } + } + + return flat +} + +/** + * Fetch historical chart data from Yahoo Finance. + * Uses yahoo-finance2's chart method which handles authentication. + * Maps to: yf.download() pattern. + */ +export async function getHistoricalData( + symbol: string, + options: { + startDate?: string | null + endDate?: string | null + interval?: string + } = {}, +): Promise[]> { + const yf = getYF() + const interval = options.interval ?? '1d' + + const period1 = options.startDate + ? new Date(options.startDate) + : new Date(Date.now() - 365 * 24 * 60 * 60 * 1000) + + const period2 = options.endDate + ? new Date(options.endDate) + : new Date() + + const chartResult = await withRetry(() => yf.chart(symbol, { + period1, + period2, + interval: interval as any, + })) + + if (!chartResult?.quotes?.length) { + throw new EmptyDataError(`No historical data for ${symbol}`) + } + + const isIntraday = ['1m', '2m', '5m', '15m', '30m', '60m', '90m', '1h'].includes(interval) + + const records: Record[] = [] + for (const q of chartResult.quotes) { + if (q.open == null || q.open <= 0) continue + + const date = q.date instanceof Date ? q.date : new Date(q.date as any) + const dateStr = isIntraday + ? date.toISOString().replace('T', ' ').slice(0, 19) + : date.toISOString().slice(0, 10) + + records.push({ + date: dateStr, + open: q.open ?? null, + high: q.high ?? null, + low: q.low ?? null, + close: q.close ?? null, + volume: q.volume ?? null, + ...(q.adjclose != null ? { adj_close: q.adjclose } : {}), + }) + } + + if (records.length === 0) { + throw new EmptyDataError(`No valid historical data for ${symbol}`) + } + + return records +} + +/** + * Search Yahoo Finance for symbols. + * Used by crypto-search and currency-search models. + */ +export async function searchYahooFinance( + query: string, +): Promise[]> { + const yf = getYF() + // validateResult: false — Yahoo changed typeDisp casing (e.g. "cryptocurrency" vs + // "Cryptocurrency"), causing yahoo-finance2's strict schema validation to throw. + const result: any = await withRetry(() => + (yf as any).search(query, { quotesCount: 20, newsCount: 0 }, { validateResult: false }), + ) + return (result.quotes ?? []) as Record[] +} + +/** + * Convert a camelCase string to snake_case. + * Maps to: openbb_core.provider.utils.helpers.to_snake_case + */ +function toSnakeCase(s: string): string { + return s.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '') +} + +/** + * Fetch financial statement data from Yahoo Finance via fundamentalsTimeSeries. + * Used by balance-sheet, income-statement, and cash-flow fetchers. + * + * Note: The old quoteSummary modules (balanceSheetHistory, incomeStatementHistory, + * cashflowStatementHistory) have been deprecated since Nov 2024 and return almost + * no data. fundamentalsTimeSeries returns ALL financial data fields mixed together. + * + * @param symbol - Stock ticker + * @param period - "annual" or "quarter" + * @param limit - max periods to return (default: 5) + */ +export async function getFinancialStatements( + symbol: string, + period: string, + limit = 5, +): Promise[]> { + const yf = getYF() + const type = period === 'quarter' ? 'quarterly' : 'annual' + + // Fetch 10 years back for annual, 3 years for quarterly + const yearsBack = period === 'quarter' ? 3 : 10 + const period1 = new Date() + period1.setFullYear(period1.getFullYear() - yearsBack) + + let result: any + try { + result = await withRetry(() => (yf as any).fundamentalsTimeSeries(symbol, { + period1: period1.toISOString().slice(0, 10), + period2: new Date().toISOString().slice(0, 10), + type, + module: 'all', + })) + recordYFSuccess() + } catch (err) { + recordYFFailure() + throw err + } + + if (!Array.isArray(result) || result.length === 0) { + throw new EmptyDataError(`No financial statement data for ${symbol}`) + } + + // Sort by date descending (most recent first) and apply limit + const sorted = result.sort((a: any, b: any) => { + const da = a.date instanceof Date ? a.date.getTime() : new Date(a.date).getTime() + const db = b.date instanceof Date ? b.date.getTime() : new Date(b.date).getTime() + return db - da + }) + const limited = sorted.slice(0, limit) + + // Convert each period's data to snake_case records + return limited.map((stmt: any) => { + const record: Record = {} + for (const [key, value] of Object.entries(stmt)) { + // Skip metadata fields + if (key === 'TYPE') continue + const snakeKey = toSnakeCase(key) + if (value instanceof Date) { + record[snakeKey] = value.toISOString().slice(0, 10) + } else if (value != null && typeof value === 'object' && 'raw' in (value as any)) { + record[snakeKey] = (value as any).raw + } else if (typeof value !== 'object' || value === null) { + record[snakeKey] = value ?? null + } + } + // Map 'date' → 'period_ending' for standard model + if (record.date && !record.period_ending) { + record.period_ending = record.date + delete record.date + } + return record + }) +} + +/** + * Fetch raw (unflattened) quoteSummary modules from Yahoo Finance. + * Unlike getQuoteSummary(), this preserves nested objects like companyOfficers. + * Useful for endpoints that need array-type nested data. + */ +export async function getRawQuoteSummary( + symbol: string, + modules: string[], +): Promise> { + const yf = getYF() + + let result: any + try { + result = await withRetry(() => yf.quoteSummary(symbol, { modules: modules as any })) + recordYFSuccess() + } catch (err) { + recordYFFailure() + throw err + } + + if (!result) { + throw new EmptyDataError(`No quote summary data for ${symbol}`) + } + + return result +} + +/** + * Fetch historical dividend data from Yahoo Finance using the chart API. + * Maps to: yfinance Ticker.get_dividends() pattern. + */ +export async function getHistoricalDividends( + symbol: string, + startDate?: string | null, + endDate?: string | null, +): Promise[]> { + const yf = getYF() + + const period1 = startDate + ? new Date(startDate) + : new Date('1970-01-01') + const period2 = endDate + ? new Date(endDate) + : new Date() + + let result: any + try { + result = await withRetry(() => yf.chart(symbol, { + period1, + period2, + interval: '1d', + events: 'div', + } as any)) + recordYFSuccess() + } catch (err) { + recordYFFailure() + throw err + } + + // Extract dividends from events + const dividends: Record[] = [] + const events = result?.events + if (events?.dividends) { + const divEntries = Array.isArray(events.dividends) + ? events.dividends + : Object.values(events.dividends) + for (const div of divEntries) { + const date = div.date instanceof Date + ? div.date.toISOString().slice(0, 10) + : typeof div.date === 'number' + ? new Date(div.date * 1000).toISOString().slice(0, 10) + : String(div.date ?? '').slice(0, 10) + dividends.push({ + ex_dividend_date: date, + amount: div.amount ?? div.dividend ?? 0, + }) + } + } + + if (!dividends.length) { + throw new EmptyDataError(`No dividend data found for ${symbol}`) + } + + // Filter by date range if specified + let filtered = dividends + if (startDate) { + filtered = filtered.filter(d => String(d.ex_dividend_date) >= startDate) + } + if (endDate) { + filtered = filtered.filter(d => String(d.ex_dividend_date) <= endDate) + } + + return filtered +} + +/** + * Get the list of futures chain symbols from Yahoo Finance. + * Uses quoteSummary with 'futuresChain' module on the continuation symbol (SYMBOL=F). + * Maps to: get_futures_symbols() in helpers.py + */ +export async function getFuturesSymbols(symbol: string): Promise { + try { + const result = await getRawQuoteSummary(`${symbol}=F`, ['futuresChain'] as any) + const chain: any = (result as any)?.futuresChain + if (chain?.futures && Array.isArray(chain.futures)) { + return chain.futures as string[] + } + } catch { + // Fall through to empty + } + return [] +} + +/** + * Get options chain data from Yahoo Finance for a symbol. + * Uses yahoo-finance2 options() with retry and instance reset logic. + */ +export async function getOptionsData( + symbol: string, + date?: Date | null, +): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + const yf = getYF() + try { + const result = date + ? await (yf as any).options(symbol, { date }) + : await (yf as any).options(symbol) + recordYFSuccess() + return result + } catch (err) { + recordYFFailure() + if (attempt === 0) { + // Force singleton reset for fresh crumb on retry + _yf = null + _yfFailCount = 0 + await new Promise(r => setTimeout(r, 1000)) + continue + } + throw err + } + } +} + +/** + * Get news from Yahoo Finance for a symbol. + */ +export async function getYahooNews( + symbol: string, + limit = 20, +): Promise[]> { + const yf = getYF() + const result = await withRetry(() => yf.search(symbol, { quotesCount: 0, newsCount: limit })) + return (result.news ?? []) as Record[] +} + diff --git a/packages/opentypebb/src/providers/yfinance/utils/references.ts b/packages/opentypebb/src/providers/yfinance/utils/references.ts new file mode 100644 index 00000000..f11b76c0 --- /dev/null +++ b/packages/opentypebb/src/providers/yfinance/utils/references.ts @@ -0,0 +1,401 @@ +/** + * Yahoo Finance References. + * Maps to: openbb_yfinance/utils/references.py + */ + +import { z } from 'zod' +import { EquityPerformanceDataSchema } from '../../../standard-models/equity-performance.js' +import { applyAliases } from '../../../core/provider/utils/helpers.js' + +export const INTERVALS_DICT: Record = { + '1m': '1m', + '2m': '2m', + '5m': '5m', + '15m': '15m', + '30m': '30m', + '60m': '60m', + '90m': '90m', + '1h': '1h', + '1d': '1d', + '5d': '5d', + '1W': '1wk', + '1M': '1mo', + '1Q': '3mo', +} + +/** Futures month code mapping. Maps month number (1-12) to futures month letter code. */ +export const MONTHS: Record = { + 1: 'F', + 2: 'G', + 3: 'H', + 4: 'J', + 5: 'K', + 6: 'M', + 7: 'N', + 8: 'Q', + 9: 'U', + 10: 'V', + 11: 'X', + 12: 'Z', +} + +/** + * Index code → { name, ticker } mapping. + * Maps to: INDICES dict in openbb_yfinance/utils/references.py + */ +export const INDICES: Record = { + sp500: { name: 'S&P 500 Index', ticker: '^GSPC' }, + spx: { name: 'S&P 500 Index', ticker: '^SPX' }, + sp400: { name: 'S&P 400 Mid Cap Index', ticker: '^SP400' }, + sp600: { name: 'S&P 600 Small Cap Index', ticker: '^SP600' }, + sp500tr: { name: 'S&P 500 TR Index', ticker: '^SP500TR' }, + sp_xsp: { name: 'S&P 500 Mini SPX Options Index', ticker: '^XSP' }, + nyse_ny: { name: 'NYSE US 100 Index', ticker: '^NY' }, + dow_djus: { name: 'Dow Jones US Index', ticker: '^DJUS' }, + nyse: { name: 'NYSE Composite Index', ticker: '^NYA' }, + amex: { name: 'NYSE-AMEX Composite Index', ticker: '^XAX' }, + nasdaq: { name: 'Nasdaq Composite Index', ticker: '^IXIC' }, + nasdaq100: { name: 'NASDAQ 100', ticker: '^NDX' }, + nasdaq100_ew: { name: 'NASDAQ 100 Equal Weighted Index', ticker: '^NDXE' }, + nasdaq50: { name: 'NASDAQ Q50 Index', ticker: '^NXTQ' }, + russell1000: { name: 'Russell 1000 Index', ticker: '^RUI' }, + russell2000: { name: 'Russell 2000 Index', ticker: '^RUT' }, + cboe_bxr: { name: 'CBOE Russell 2000 Buy-Write Index', ticker: '^BXR' }, + cboe_bxrt: { name: 'CBOE Russell 2000 30-Delta Buy-Write Index', ticker: '^BXRT' }, + russell3000: { name: 'Russell 3000 Index', ticker: '^RUA' }, + russellvalue: { name: 'Russell 2000 Value Index', ticker: '^RUJ' }, + russellgrowth: { name: 'Russell 2000 Growth Index', ticker: '^RUO' }, + w5000: { name: 'Wilshire 5000', ticker: '^W5000' }, + w5000flt: { name: 'Wilshire 5000 Float Adjusted Index', ticker: '^W5000FLT' }, + dow_dja: { name: 'Dow Jones Composite Average Index', ticker: '^DJA' }, + dow_dji: { name: 'Dow Jones Industrial Average Index', ticker: '^DJI' }, + ca_tsx: { name: 'TSX Composite Index (CAD)', ticker: '^GSPTSE' }, + ca_banks: { name: 'S&P/TSX Composite Banks Index (CAD)', ticker: 'TXBA.TS' }, + mx_ipc: { name: 'IPC Mexico Index (MXN)', ticker: '^MXX' }, + arca_mxy: { name: 'NYSE ARCA Mexico Index (USD)', ticker: '^MXY' }, + br_bvsp: { name: 'IBOVESPA Sao Paulo Brazil Index (BRL)', ticker: '^BVSP' }, + br_ivbx: { name: 'IVBX2 Indice Valour (BRL)', ticker: '^IVBX' }, + ar_mervel: { name: 'S&P MERVAL TR Index (USD)', ticker: 'M.BA' }, + eu_fteu1: { name: 'FTSE Eurotop 100 Index (EUR)', ticker: '^FTEU1' }, + eu_speup: { name: 'S&P Europe 350 Index (EUR)', ticker: '^SPEUP' }, + eu_n100: { name: 'Euronext 100 Index (EUR)', ticker: '^N100' }, + ftse100: { name: 'FTSE Global 100 Index (GBP)', ticker: '^FTSE' }, + ftse250: { name: 'FTSE Global 250 Index (GBP)', ticker: '^FTMC' }, + ftse350: { name: 'FTSE Global 350 Index (GBP)', ticker: '^FTLC' }, + ftai: { name: 'FTSE AIM All-Share Global Index (GBP)', ticker: '^FTAI' }, + uk_ftas: { name: 'UK FTSE All-Share Index (GBP)', ticker: '^FTAS' }, + uk_spuk: { name: 'S&P United Kingdom Index (PDS)', ticker: '^SPUK' }, + uk_100: { name: 'CBOE UK 100 Index (GBP)', ticker: '^BUK100P' }, + ie_iseq: { name: 'ISEQ Irish All Shares Index (EUR)', ticker: '^ISEQ' }, + nl_aex: { name: 'Euronext Dutch 25 Index (EUR)', ticker: '^AEX' }, + nl_amx: { name: 'Euronext Dutch Mid Cap Index (EUR)', ticker: '^AMX' }, + at_atx: { name: 'Wiener Börse Austrian 20 Index (EUR)', ticker: '^ATX' }, + at_atx5: { name: 'Vienna ATX Five Index (EUR)', ticker: '^ATX5' }, + at_prime: { name: 'Vienna ATX Prime Index (EUR)', ticker: '^ATXPRIME' }, + ch_stoxx: { name: 'Zurich STXE 600 PR Index (EUR)', ticker: '^STOXX' }, + ch_stoxx50e: { name: 'Zurich ESTX 50 PR Index (EUR)', ticker: '^STOXX50E' }, + se_omx30: { name: 'OMX Stockholm 30 Index (SEK)', ticker: '^OMX' }, + se_omxspi: { name: 'OMX Stockholm All Share PI (SEK)', ticker: '^OMXSPI' }, + se_benchmark: { name: 'OMX Stockholm Benchmark GI (SEK)', ticker: '^OMXSBGI' }, + dk_benchmark: { name: 'OMX Copenhagen Benchmark GI (DKK)', ticker: '^OMXCBGI' }, + dk_omxc25: { name: 'OMX Copenhagen 25 Index (DKK)', ticker: '^OMXC25' }, + fi_omxh25: { name: 'OMX Helsinki 25 (EUR)', ticker: '^OMXH25' }, + de_dax40: { name: 'DAX Performance Index (EUR)', ticker: '^GDAXI' }, + de_mdax60: { name: 'DAX Mid Cap Performance Index (EUR)', ticker: '^MDAXI' }, + de_sdax70: { name: 'DAX Small Cap Performance Index (EUR)', ticker: '^SDAXI' }, + de_tecdax30: { name: 'DAX Tech Sector TR Index (EUR)', ticker: '^TECDAX' }, + fr_cac40: { name: 'CAC 40 PR Index (EUR)', ticker: '^FCHI' }, + fr_next20: { name: 'CAC Next 20 Index (EUR)', ticker: '^CN20' }, + it_mib40: { name: 'FTSE MIB 40 Index (EUR)', ticker: 'FTSEMIB.MI' }, + be_bel20: { name: 'BEL 20 Brussels Index (EUR)', ticker: '^BFX' }, + pt_bvlg: { name: 'Lisbon PSI All-Share Index GR (EUR)', ticker: '^BVLG' }, + es_ibex35: { name: 'IBEX 35 - Madrid CATS (EUR)', ticker: '^IBEX' }, + in_bse: { name: 'S&P Bombay SENSEX (INR)', ticker: '^BSESN' }, + in_bse500: { name: 'S&P BSE 500 Index (INR)', ticker: 'BSE-500.BO' }, + in_bse200: { name: 'S&P BSE 200 Index (INR)', ticker: 'BSE-200.BO' }, + in_bse100: { name: 'S&P BSE 100 Index (INR)', ticker: 'BSE-100.BO' }, + in_bse_mcap: { name: 'S&P Bombay Mid Cap Index (INR)', ticker: 'BSE-MIDCAP.BO' }, + in_bse_scap: { name: 'S&P Bombay Small Cap Index (INR)', ticker: 'BSE-SMLCAP.BO' }, + in_nse50: { name: 'NSE Nifty 50 Index (INR)', ticker: '^NSEI' }, + in_nse_mcap: { name: 'NSE Nifty 50 Mid Cap Index (INR)', ticker: '^NSEMDCP50' }, + in_nse_bank: { name: 'NSE Nifty Bank Industry Index (INR)', ticker: '^NSEBANK' }, + in_nse500: { name: 'NSE Nifty 500 Index (INR)', ticker: '^CRSLDX' }, + il_ta125: { name: 'Tel-Aviv 125 Index (ILS)', ticker: '^TA125.TA' }, + za_shariah: { name: 'Johannesburg Shariah All Share Index (ZAR)', ticker: '^J143.JO' }, + za_jo: { name: 'Johannesburg All Share Index (ZAR)', ticker: '^J203.JO' }, + za_jo_mcap: { name: 'Johannesburg Large and Mid Cap Index (ZAR)', ticker: '^J206.JO' }, + za_jo_altex: { name: 'Johannesburg Alt Exchange Index (ZAR)', ticker: '^J232.JO' }, + ru_moex: { name: 'MOEX Russia Index (RUB)', ticker: 'IMOEX.ME' }, + au_aord: { name: 'Australia All Ordinary Share Index (AUD)', ticker: '^AORD' }, + au_small: { name: 'S&P/ASX Small Ordinaries Index (AUD)', ticker: '^AXSO' }, + au_asx20: { name: 'S&P/ASX 20 Index (AUD)', ticker: '^ATLI' }, + au_asx50: { name: 'S&P/ASX 50 Index (AUD)', ticker: '^AFLI' }, + au_asx50_mid: { name: 'S&P/ASX Mid Cap 50 Index (AUD)', ticker: '^AXMD' }, + au_asx100: { name: 'S&P/ASX 100 Index (AUD)', ticker: '^ATOI' }, + au_asx200: { name: 'S&P/ASX 200 Index (AUD)', ticker: '^AXJO' }, + au_asx300: { name: 'S&P/ASX 300 Index (AUD)', ticker: '^AXKO' }, + au_energy: { name: 'S&P/ASX 200 Energy Sector Index (AUD)', ticker: '^AXEJ' }, + au_resources: { name: 'S&P/ASX 200 Resources Sector Index (AUD)', ticker: '^AXJR' }, + au_materials: { name: 'S&P/ASX 200 Materials Sector Index (AUD)', ticker: '^AXMJ' }, + au_mining: { name: 'S&P/ASX 300 Metals and Mining Sector Index (AUD)', ticker: '^AXMM' }, + au_industrials: { name: 'S&P/ASX 200 Industrials Sector Index (AUD)', ticker: '^AXNJ' }, + au_discretionary: { name: 'S&P/ASX 200 Consumer Discretionary Sector Index (AUD)', ticker: '^AXDJ' }, + au_staples: { name: 'S&P/ASX 200 Consumer Staples Sector Index (AUD)', ticker: '^AXSJ' }, + au_health: { name: 'S&P/ASX 200 Health Care Sector Index (AUD)', ticker: '^AXHJ' }, + au_financials: { name: 'S&P/ASX 200 Financials Sector Index (AUD)', ticker: '^AXFJ' }, + au_reit: { name: 'S&P/ASX 200 A-REIT Industry Index (AUD)', ticker: '^AXPJ' }, + au_tech: { name: 'S&P/ASX 200 Info Tech Sector Index (AUD)', ticker: '^AXIJ' }, + au_communications: { name: 'S&P/ASX 200 Communications Sector Index (AUD)', ticker: '^AXTJ' }, + au_utilities: { name: 'S&P/ASX 200 Utilities Sector Index (AUD)', ticker: '^AXUJ' }, + nz50: { name: 'S&P New Zealand 50 Index (NZD)', ticker: '^nz50' }, + nz_small: { name: 'S&P/NZX Small Cap Index (NZD)', ticker: '^NZSC' }, + kr_kospi: { name: 'KOSPI Composite Index (KRW)', ticker: '^KS11' }, + jp_arca: { name: 'NYSE ARCA Japan Index (JPY)', ticker: '^JPN' }, + jp_n225: { name: 'Nikkei 255 Index (JPY)', ticker: '^N225' }, + jp_n300: { name: 'Nikkei 300 Index (JPY)', ticker: '^N300' }, + jp_nknr: { name: 'Nikkei Avg Net TR Index (JPY)', ticker: '^NKVI.OS' }, + jp_nkrc: { name: 'Nikkei Avg Risk Control Index (JPY)', ticker: '^NKRC.OS' }, + jp_nklv: { name: 'Nikkei Avg Leverage Index (JPY)', ticker: '^NKLV.OS' }, + jp_nkcc: { name: 'Nikkei Avg Covered Call Index (JPY)', ticker: '^NKCC.OS' }, + jp_nkhd: { name: 'Nikkei Avg High Dividend Yield Index (JPY)', ticker: '^NKHD.OS' }, + jp_auto: { name: 'Nikkei 500 Auto & Auto Parts Index (JPY)', ticker: '^NG17.OS' }, + jp_fintech: { name: 'Global Fintech Japan Hedged Index (JPY)', ticker: '^FDSFTPRJPY' }, + jp_nkdh: { name: 'Nikkei Average USD Hedge Index (JPY)', ticker: '^NKDH.OS' }, + jp_nkeh: { name: 'Nikkei Average EUR Hedge Index (JPY)', ticker: '^NKEH.OS' }, + jp_ndiv: { name: 'Nikkei Average Double Inverse Index (JPY)', ticker: '^NDIV.OS' }, + cn_csi300: { name: 'China CSI 300 Index (CNY)', ticker: '000300.SS' }, + cn_sse_comp: { name: 'SSE Composite Index (CNY)', ticker: '000001.SS' }, + cn_sse_a: { name: 'SSE A Share Index (CNY)', ticker: '000002.SS' }, + cn_szse_comp: { name: 'SZSE Component Index (CNY)', ticker: '399001.SZ' }, + cn_szse_a: { name: 'SZSE A-Shares Index (CNY)', ticker: '399107.SZ' }, + tw_twii: { name: 'TSEC Weighted Index (TWD)', ticker: '^TWII' }, + tw_tcii: { name: 'TSEC Cement and Ceramics Subindex (TWD)', ticker: '^TCII' }, + tw_tfii: { name: 'TSEC Foods Subindex (TWD)', ticker: '^TFII' }, + tw_tfni: { name: 'TSEC Finance Subindex (TWD)', ticker: '^TFNI' }, + tw_tpai: { name: 'TSEC Paper and Pulp Subindex (TWD)', ticker: '^TPAI' }, + hk_hsi: { name: 'Hang Seng Index (HKD)', ticker: '^HSI' }, + hk_utilities: { name: 'Hang Seng Utilities Sector Index (HKD)', ticker: '^HSNU' }, + hk_china: { name: 'Hang Seng China-Affiliated Corporations Index (HKD)', ticker: '^HSCC' }, + hk_finance: { name: 'Hang Seng Finance Sector Index (HKD)', ticker: '^HSNF' }, + hk_properties: { name: 'Hang Seng Properties Sector Index (HKD)', ticker: '^HSNP' }, + hk_hko: { name: 'NYSE ARCA Hong Kong Options Index (USD)', ticker: '^HKO' }, + hk_titans30: { name: 'Dow Jones Hong Kong Titans 30 Index (HKD)', ticker: '^XLHK' }, + id_jkse: { name: 'Jakarta Composite Index (IDR)', ticker: '^JKSE' }, + id_lq45: { name: 'Indonesia Stock Exchange LQ45 Index (IDR)', ticker: '^JKLQ45' }, + my_klci: { name: 'FTSE Kuala Lumpur Composite Index (MYR)', ticker: '^KLSE' }, + ph_psei: { name: 'Philippine Stock Exchange Index (PHP)', ticker: 'PSEI.PS' }, + sg_sti: { name: 'STI Singapore Index (SGD)', ticker: '^STI' }, + th_set: { name: 'Thailand SET Index (THB)', ticker: '^SET.BK' }, + sp_energy_ig: { name: 'S&P 500 Energy (Industry Group) Index', ticker: '^SP500-1010' }, + sp_energy_equipment: { name: 'S&P 500 Energy Equipment & Services Industry Index', ticker: '^SP500-101010' }, + sp_energy_oil: { name: 'S&P 500 Oil, Gas & Consumable Fuels Industry Index', ticker: '^SP500-101020' }, + sp_materials_sector: { name: 'S&P 500 Materials Sector Index', ticker: '^SP500-15' }, + sp_materials_ig: { name: 'S&P 500 Materials (Industry Group) Index', ticker: '^SP500-1510' }, + sp_materials_construction: { name: 'S&P 500 Construction Materials Industry Index', ticker: '^SP500-151020' }, + sp_materials_metals: { name: 'S&P 500 Mining & Metals Industry Index', ticker: '^SP500-151040' }, + sp_industrials_sector: { name: 'S&P 500 Industrials Sector Index', ticker: '^SP500-20' }, + sp_industrials_goods_ig: { name: 'S&P 500 Capital Goods (Industry Group) Index', ticker: '^SP500-2010' }, + sp_industrials_aerospace: { name: 'S&P 500 Aerospace & Defense Industry Index', ticker: '^SP500-201010' }, + sp_industrials_building: { name: 'S&P 500 Building Products Industry Index', ticker: '^SP500-201020' }, + sp_industrials_construction: { name: 'S&P 500 Construction & Engineering Industry Index', ticker: '^SP500-201030' }, + sp_industrials_electrical: { name: 'S&P 500 Electrical Equipment Industry Index', ticker: '^SP500-201040' }, + sp_industrials_conglomerates: { name: 'S&P 500 Industrial Conglomerates Industry Index', ticker: '^SP500-201050' }, + sp_industrials_machinery: { name: 'S&P 500 Machinery Industry Index', ticker: '^SP500-201060' }, + sp_industrials_distributors: { name: 'S&P 500 Trading Companies & Distributors Industry Index', ticker: '^SP500-201070' }, + sp_industrials_services_ig: { name: 'S&P 500 Commercial & Professional Services (Industry Group) Index', ticker: '^SP500-2020' }, + sp_industrials_services_supplies: { name: 'S&P 500 Commercial Services & Supplies Industry Index', ticker: '^SP500-202010' }, + sp_industrials_transport_ig: { name: 'S&P 500 Transportation (Industry Group) Index', ticker: '^SP500-2030' }, + sp_industrials_transport_air: { name: 'S&P 500 Air Freight & Logistics Industry', ticker: '^SP500-203010' }, + sp_industrials_transport_airlines: { name: 'S&P 500 Airlines Industry Index', ticker: '^SP500-203020' }, + sp_industrials_transport_ground: { name: 'S&P 500 Road & Rail Industry Index', ticker: '^SP500-203040' }, + sp_discretionary_sector: { name: 'S&P 500 Consumer Discretionary Index', ticker: '^SP500-25' }, + sp_discretionary_autos_ig: { name: 'S&P 500 Automobiles and Components (Industry Group) Index', ticker: '^SP500-2510' }, + sp_discretionary_auto_components: { name: 'S&P 500 Auto Components Industry Index', ticker: '^SP500-251010' }, + sp_discretionary_autos: { name: 'S&P 500 Automobiles Industry Index', ticker: '^SP500-251020' }, + sp_discretionary_durables_ig: { name: 'S&P 500 Consumer Durables & Apparel (Industry Group) Index', ticker: '^SP500-2520' }, + sp_discretionary_durables_household: { name: 'S&P 500 Household Durables Industry Index', ticker: '^SP500-252010' }, + sp_discretionary_leisure: { name: 'S&P 500 Leisure Products Industry Index', ticker: '^SP500-252020' }, + sp_discretionary_textiles: { name: 'S&P 500 Textiles, Apparel & Luxury Goods Industry Index', ticker: '^SP500-252030' }, + sp_discretionary_services_consumer: { name: 'S&P 500 Consumer Services (Industry Group) Index', ticker: '^SP500-2530' }, + sp_staples_sector: { name: 'S&P 500 Consumer Staples Sector Index', ticker: '^SP500-30' }, + sp_staples_retail_ig: { name: 'S&P 500 Food & Staples Retailing (Industry Group) Index', ticker: '^SP500-3010' }, + sp_staples_food_ig: { name: 'S&P 500 Food Beverage & Tobacco (Industry Group) Index', ticker: '^SP500-3020' }, + sp_staples_beverages: { name: 'S&P 500 Beverages Industry Index', ticker: '^SP500-302010' }, + sp_staples_products_food: { name: 'S&P 500 Food Products Industry Index', ticker: '^SP500-302020' }, + sp_staples_tobacco: { name: 'S&P 500 Tobacco Industry Index', ticker: '^SP500-302030' }, + sp_staples_household_ig: { name: 'S&P 500 Household & Personal Products (Industry Group) Index', ticker: '^SP500-3030' }, + sp_staples_products_household: { name: 'S&P 500 Household Products Industry Index', ticker: '^SP500-303010' }, + sp_staples_products_personal: { name: 'S&P 500 Personal Products Industry Index', ticker: '^SP500-303020' }, + sp_health_sector: { name: 'S&P 500 Health Care Sector Index', ticker: '^SP500-35' }, + sp_health_equipment: { name: 'S&P 500 Health Care Equipment & Services (Industry Group) Index', ticker: '^SP500-3510' }, + sp_health_supplies: { name: 'S&P 500 Health Care Equipment & Supplies Industry Index', ticker: '^SP500-351010' }, + sp_health_providers: { name: 'S&P 500 Health Care Providers & Services Industry Index', ticker: '^SP500-351020' }, + sp_health_sciences: { name: 'S&P 500 Pharmaceuticals, Biotechnology & Life Sciences (Industry Group) Index', ticker: '^SP500-3520' }, + sp_health_biotech: { name: 'S&P 500 Biotechnology Industry Index', ticker: '^SP500-352010' }, + sp_health_pharma: { name: 'S&P 500 Pharmaceuticals Industry Index', ticker: '^SP500-352020' }, + sp_financials_sector: { name: 'S&P 500 Financials Sector Index', ticker: '^SP500-40' }, + sp_financials_diversified_ig: { name: 'S&P 500 Diversified Financials (Industry Group) Index', ticker: '^SP500-4020' }, + sp_financials_services: { name: 'S&P 500 Diversified Financial Services Industry Index', ticker: '^SP500-402010' }, + sp_financials_consumer: { name: 'S&P 500 Consumer Finance Industry Index', ticker: '^SP500-402020' }, + sp_financials_capital: { name: 'S&P 500 Capital Markets Industry Index', ticker: '^SP500-402030' }, + sp_it_sector: { name: 'S&P 500 IT Sector Index', ticker: '^SP500-45' }, + sp_it_saas_ig: { name: 'S&P 500 Software and Services (Industry Group) Index', ticker: '^SP500-4510' }, + sp_it_software: { name: 'S&P 500 Software Industry Index', ticker: '^SP500-451030' }, + sp_it_hardware: { name: 'S&P 500 Technology Hardware Equipment (Industry Group) Index', ticker: '^SP500-4520' }, + sp_it_semi: { name: 'S&P 500 Semiconductor & Semiconductor Equipment Industry', ticker: '^SP500-453010' }, + sp_communications_sector: { name: 'S&P 500 Communications Sector Index', ticker: '^SP500-50' }, + sp_communications_telecom: { name: 'S&P 500 Diversified Telecommunications Services Industry Index', ticker: '^SP500-501010' }, + sp_utilities_sector: { name: 'S&P 500 Utilities Sector Index', ticker: '^SP500-55' }, + sp_utilities_electricity: { name: 'S&P 500 Electric Utilities Index', ticker: '^SP500-551010' }, + sp_utilities_multis: { name: 'S&P 500 Multi-Utilities Industry Index', ticker: '^SP500-551030' }, + sp_re_sector: { name: 'S&P 500 Real Estate Sector Index', ticker: '^SP500-60' }, + sp_re_ig: { name: 'S&P 500 Real Estate (Industry Group) Index', ticker: '^SP500-6010' }, + sphyda: { name: 'S&P High Yield Aristocrats Index', ticker: '^SPHYDA' }, + dow_djt: { name: 'Dow Jones Transportation Average Index', ticker: '^DJT' }, + dow_dju: { name: 'Dow Jones Utility Average Index', ticker: '^DJU' }, + dow_rci: { name: 'Dow Jones Composite All REIT Index', ticker: '^RCI' }, + reit_fnar: { name: 'FTSE Nareit All Equity REITs Index', ticker: '^FNAR' }, + nq_ixch: { name: 'NASDAQ Health Care Index', ticker: '^IXCH' }, + nq_nbi: { name: 'NASDAQ Biotech Index', ticker: '^NBI' }, + nq_tech: { name: 'NASDAQ 100 Technology Sector Index', ticker: '^NDXT' }, + nq_ex_tech: { name: 'NASDAQ 100 Ex-Tech Sector Index', ticker: '^NDXX' }, + nq_ixtc: { name: 'NASDAQ Telecommunications Index', ticker: '^IXTC' }, + nq_inds: { name: 'NASDAQ Industrial Index', ticker: '^INDS' }, + nq_ixco: { name: 'NASDAQ Computer Index', ticker: '^INCO' }, + nq_bank: { name: 'NASDAQ Bank Index', ticker: '^BANK' }, + nq_bkx: { name: 'KBW NASDAQ Bank Index', ticker: '^BKX' }, + nq_krx: { name: 'KBW NASDAQ Regional Bank Index', ticker: '^KRX' }, + nq_kix: { name: 'KBW NASDAQ Insurance Index', ticker: '^KIX' }, + nq_ksx: { name: 'KBW NASDAQ Capital Markets Index', ticker: '^KSX' }, + nq_tran: { name: 'NASDAQ Transportation Index', ticker: '^TRAN' }, + ice_auto: { name: 'ICE FactSet Global NextGen Auto Index', ticker: '^ICEFSNA' }, + ice_comm: { name: 'ICE FactSet Global NextGen Communications Index', ticker: '^ICEFSNC' }, + nyse_nyl: { name: 'NYSE World Leaders Index', ticker: '^NYL' }, + nyse_nyi: { name: 'NYSE International 100 Index', ticker: '^NYI' }, + nyse_nyy: { name: 'NYSE TMT Index', ticker: '^NYY' }, + nyse_fang: { name: 'NYSE FANG+TM index', ticker: '^NYFANG' }, + arca_xmi: { name: 'NYSE ARCA Major Market Index', ticker: '^XMI' }, + arca_xbd: { name: 'NYSE ARCA Securities Broker/Dealer Index', ticker: '^XBD' }, + arca_xii: { name: 'NYSE ARCA Institutional Index', ticker: '^XII' }, + arca_xoi: { name: 'NYSE ARCA Oil and Gas Index', ticker: '^XOI' }, + arca_xng: { name: 'NYSE ARCA Natural Gas Index', ticker: '^XNG' }, + arca_hui: { name: 'NYSE ARCA Gold Bugs Index', ticker: '^HUI' }, + arca_ixb: { name: 'NYSE Materials Select Sector Index', ticker: '^IXB' }, + arca_drg: { name: 'NYSE ARCA Pharmaceutical Index', ticker: '^DRG' }, + arca_btk: { name: 'NYSE ARCA Biotech Index', ticker: '^BKT' }, + arca_pse: { name: 'NYSE ARCA Tech 100 Index', ticker: '^PSE' }, + arca_nwx: { name: 'NYSE ARCA Networking Index', ticker: '^NWX' }, + arca_xci: { name: 'NYSE ARCA Computer Tech Index', ticker: '^XCI' }, + arca_xal: { name: 'NYSE ARCA Airline Index', ticker: '^XAL' }, + arca_xtc: { name: 'NYSE ARCA N.A. Telecom Industry Index', ticker: '^XTC' }, + phlx_sox: { name: 'PHLX Semiconductor Index', ticker: '^SOX' }, + phlx_xau: { name: 'PHLX Gold/Silver Index', ticker: '^XAU' }, + phlx_hgx: { name: 'PHLX Housing Sector Index', ticker: '^HGX' }, + phlx_osx: { name: 'PHLX Oil Services Sector Index', ticker: '^OSX' }, + phlx_uty: { name: 'PHLX Utility Sector Index', ticker: '^UTY' }, + w5klcg: { name: 'Wilshire US Large Cap Growth Index', ticker: '^W5KLCG' }, + w5klcv: { name: 'Wilshire US Large Cap Value Index', ticker: '^W5KLCV' }, + reit_wgreit: { name: 'Wilshire Global REIT Index', ticker: '^WGREIT' }, + reit_wgresi: { name: 'Wilshire Global Real Estate Sector Index', ticker: '^WGRESI' }, + reit_wilreit: { name: 'Wilshire US REIT Index', ticker: '^WILREIT' }, + reit_wilresi: { name: 'Wilshire US Real Estate Security Index', ticker: '^WILRESI' }, + cboe_bxm: { name: 'CBOE Buy-Write Monthly Index', ticker: '^BXM' }, + cboe_vix: { name: 'CBOE S&P 500 Volatility Index', ticker: '^VIX' }, + cboe_vix9d: { name: 'CBOE S&P 500 9-Day Volatility Index', ticker: '^VIX9D' }, + cboe_vix3m: { name: 'CBOE S&P 500 3-Month Volatility Index', ticker: '^VIX3M' }, + cboe_vin: { name: 'CBOE Near-Term VIX Index', ticker: '^VIN' }, + cboe_vvix: { name: 'CBOE VIX Volatility Index', ticker: '^VVIX' }, + cboe_shortvol: { name: 'CBOE Short VIX Futures Index', ticker: '^SHORTVOL' }, + cboe_skew: { name: 'CBOE Skew Index', ticker: '^SKEW' }, + cboe_vxn: { name: 'CBOE NASDAQ 100 Volatility Index', ticker: '^VXN' }, + cboe_gvz: { name: 'CBOE Gold Volatility Index', ticker: '^GVZ' }, + cboe_ovx: { name: 'CBOE Crude Oil Volatility Index', ticker: '^OVX' }, + cboe_tnx: { name: 'CBOE Interest Rate 10 Year T-Note', ticker: '^TNX' }, + cboe_tyx: { name: 'CBOE 30 year Treasury Yields', ticker: '^TYX' }, + cboe_irx: { name: 'CBOE 13 Week Treasury Bill', ticker: '^IRX' }, + cboe_evz: { name: 'CBOE Euro Currency Volatility Index', ticker: '^EVZ' }, + cboe_rvx: { name: 'CBOE Russell 2000 Volatility Index', ticker: '^RVX' }, + move: { name: 'ICE BofAML Move Index', ticker: '^MOVE' }, + dxy: { name: 'US Dollar Index', ticker: 'DX-Y.NYB' }, + crypto200: { name: 'CMC Crypto 200 Index by Solacti', ticker: '^CMC200' }, +} + +export const SCREENER_FIELDS = [ + 'symbol', + 'shortName', + 'regularMarketPrice', + 'regularMarketChange', + 'regularMarketChangePercent', + 'regularMarketVolume', + 'regularMarketOpen', + 'regularMarketDayHigh', + 'regularMarketDayLow', + 'regularMarketPreviousClose', + 'fiftyDayAverage', + 'twoHundredDayAverage', + 'fiftyTwoWeekHigh', + 'fiftyTwoWeekLow', + 'marketCap', + 'sharesOutstanding', + 'epsTrailingTwelveMonths', + 'forwardPE', + 'epsForward', + 'bookValue', + 'priceToBook', + 'trailingAnnualDividendYield', + 'currency', + 'exchange', + 'exchangeTimezoneName', + 'earnings_date', +] as const + +export const YF_SCREENER_ALIAS_DICT: Record = { + name: 'shortName', + price: 'regularMarketPrice', + change: 'regularMarketChange', + percent_change: 'regularMarketChangePercent', + volume: 'regularMarketVolume', + open: 'regularMarketOpen', + high: 'regularMarketDayHigh', + low: 'regularMarketDayLow', + previous_close: 'regularMarketPreviousClose', + ma50: 'fiftyDayAverage', + ma200: 'twoHundredDayAverage', + year_high: 'fiftyTwoWeekHigh', + year_low: 'fiftyTwoWeekLow', + market_cap: 'marketCap', + shares_outstanding: 'sharesOutstanding', + book_value: 'bookValue', + price_to_book: 'priceToBook', + eps_ttm: 'epsTrailingTwelveMonths', + pe_forward: 'forwardPE', + dividend_yield: 'trailingAnnualDividendYield', + earnings_date: 'earnings_date', + currency: 'currency', + exchange_timezone: 'exchangeTimezoneName', +} + +export const YFPredefinedScreenerDataSchema = EquityPerformanceDataSchema.extend({ + open: z.number().nullable().default(null).describe('Open price for the day.'), + high: z.number().nullable().default(null).describe('High price for the day.'), + low: z.number().nullable().default(null).describe('Low price for the day.'), + previous_close: z.number().nullable().default(null).describe('Previous close price.'), + ma50: z.number().nullable().default(null).describe('50-day moving average.'), + ma200: z.number().nullable().default(null).describe('200-day moving average.'), + year_high: z.number().nullable().default(null).describe('52-week high.'), + year_low: z.number().nullable().default(null).describe('52-week low.'), + market_cap: z.number().nullable().default(null).describe('Market Cap.'), + shares_outstanding: z.number().nullable().default(null).describe('Shares outstanding.'), + book_value: z.number().nullable().default(null).describe('Book value per share.'), + price_to_book: z.number().nullable().default(null).describe('Price to book ratio.'), + eps_ttm: z.number().nullable().default(null).describe('Earnings per share over the trailing twelve months.'), + eps_forward: z.number().nullable().default(null).describe('Forward earnings per share.'), + pe_forward: z.number().nullable().default(null).describe('Forward price-to-earnings ratio.'), + dividend_yield: z.number().nullable().default(null).describe('Trailing twelve month dividend yield.'), + exchange: z.string().nullable().default(null).describe('Exchange where the stock is listed.'), + exchange_timezone: z.string().nullable().default(null).describe('Timezone of the exchange.'), + earnings_date: z.string().nullable().default(null).describe('Most recent earnings date.'), + currency: z.string().nullable().default(null).describe('Currency of the price data.'), +}).passthrough() + +export type YFPredefinedScreenerData = z.infer diff --git a/packages/opentypebb/src/server.ts b/packages/opentypebb/src/server.ts new file mode 100644 index 00000000..9f36d151 --- /dev/null +++ b/packages/opentypebb/src/server.ts @@ -0,0 +1,42 @@ +/** + * OpenTypeBB — HTTP Server entry point. + * + * Usage: + * npx tsx src/server.ts + * # or after build: + * node dist/server.js + * + * Environment variables: + * OPENTYPEBB_PORT — Server port (default: 6901) + * FMP_API_KEY — Financial Modeling Prep API key + * + * Credentials can also be passed per-request via: + * X-OpenBB-Credentials: {"fmp_api_key": "..."} + */ + +import { setupProxy } from './core/utils/proxy.js' +import { createApp, startServer } from './core/api/rest-api.js' +import { createExecutor, loadAllRouters } from './core/api/app-loader.js' + +// Must be called before any fetch() calls +setupProxy() + +// Build default credentials from environment variables +const defaultCredentials: Record = {} +if (process.env.FMP_API_KEY) { + defaultCredentials.fmp_api_key = process.env.FMP_API_KEY +} + +// Create executor with all providers loaded +const executor = createExecutor() + +// Create Hono app +const app = createApp(defaultCredentials) + +// Load and mount all extension routers +const rootRouter = loadAllRouters() +rootRouter.mountToHono(app, executor) + +// Start server +const port = parseInt(process.env.OPENTYPEBB_PORT ?? '6901', 10) +startServer(app, port) diff --git a/packages/opentypebb/src/standard-models/analyst-estimates.ts b/packages/opentypebb/src/standard-models/analyst-estimates.ts new file mode 100644 index 00000000..11772680 --- /dev/null +++ b/packages/opentypebb/src/standard-models/analyst-estimates.ts @@ -0,0 +1,43 @@ +/** + * Analyst Estimates Standard Model. + * Maps to: standard_models/analyst_estimates.py + */ + +import { z } from 'zod' + +// --- Query Params --- + +export const AnalystEstimatesQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), +}) + +export type AnalystEstimatesQueryParams = z.infer + +// --- Data --- + +export const AnalystEstimatesDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity.'), + date: z.string().describe('The date of the data.'), + estimated_revenue_low: z.number().nullable().default(null).describe('Estimated revenue low.'), + estimated_revenue_high: z.number().nullable().default(null).describe('Estimated revenue high.'), + estimated_revenue_avg: z.number().nullable().default(null).describe('Estimated revenue average.'), + estimated_sga_expense_low: z.number().nullable().default(null).describe('Estimated SGA expense low.'), + estimated_sga_expense_high: z.number().nullable().default(null).describe('Estimated SGA expense high.'), + estimated_sga_expense_avg: z.number().nullable().default(null).describe('Estimated SGA expense average.'), + estimated_ebitda_low: z.number().nullable().default(null).describe('Estimated EBITDA low.'), + estimated_ebitda_high: z.number().nullable().default(null).describe('Estimated EBITDA high.'), + estimated_ebitda_avg: z.number().nullable().default(null).describe('Estimated EBITDA average.'), + estimated_ebit_low: z.number().nullable().default(null).describe('Estimated EBIT low.'), + estimated_ebit_high: z.number().nullable().default(null).describe('Estimated EBIT high.'), + estimated_ebit_avg: z.number().nullable().default(null).describe('Estimated EBIT average.'), + estimated_net_income_low: z.number().nullable().default(null).describe('Estimated net income low.'), + estimated_net_income_high: z.number().nullable().default(null).describe('Estimated net income high.'), + estimated_net_income_avg: z.number().nullable().default(null).describe('Estimated net income average.'), + estimated_eps_low: z.number().nullable().default(null).describe('Estimated EPS low.'), + estimated_eps_high: z.number().nullable().default(null).describe('Estimated EPS high.'), + estimated_eps_avg: z.number().nullable().default(null).describe('Estimated EPS average.'), + number_analyst_estimated_revenue: z.number().nullable().default(null).describe('Number of analysts estimating revenue.'), + number_analysts_estimated_eps: z.number().nullable().default(null).describe('Number of analysts estimating EPS.'), +}).passthrough() + +export type AnalystEstimatesData = z.infer diff --git a/packages/opentypebb/src/standard-models/available-indicators.ts b/packages/opentypebb/src/standard-models/available-indicators.ts new file mode 100644 index 00000000..6bbd7524 --- /dev/null +++ b/packages/opentypebb/src/standard-models/available-indicators.ts @@ -0,0 +1,21 @@ +/** + * Available Indicators Standard Model. + * Maps to: openbb_core/provider/standard_models/available_indicators.py + */ + +import { z } from 'zod' + +export const AvailableIndicatorsQueryParamsSchema = z.object({}).passthrough() + +export type AvailableIndicatorsQueryParams = z.infer + +export const AvailableIndicatorsDataSchema = z.object({ + symbol_root: z.string().nullable().default(null).describe('The root symbol representing the indicator.'), + symbol: z.string().nullable().default(null).describe('Symbol representing the entity.'), + country: z.string().nullable().default(null).describe('The name of the country, region, or entity represented by the symbol.'), + iso: z.string().nullable().default(null).describe('The ISO code of the country, region, or entity.'), + description: z.string().nullable().default(null).describe('The description of the indicator.'), + frequency: z.string().nullable().default(null).describe('The frequency of the indicator data.'), +}).passthrough() + +export type AvailableIndicatorsData = z.infer diff --git a/packages/opentypebb/src/standard-models/available-indices.ts b/packages/opentypebb/src/standard-models/available-indices.ts new file mode 100644 index 00000000..131df739 --- /dev/null +++ b/packages/opentypebb/src/standard-models/available-indices.ts @@ -0,0 +1,19 @@ +/** + * Available Indices Standard Model. + * Maps to: openbb_core/provider/standard_models/available_indices.py + */ + +import { z } from 'zod' + +export const AvailableIndicesQueryParamsSchema = z.object({}).passthrough() + +export type AvailableIndicesQueryParams = z.infer + +export const AvailableIndicesDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity.'), + name: z.string().nullable().default(null).describe('Name of the index.'), + exchange: z.string().nullable().default(null).describe('Stock exchange where the index is listed.'), + currency: z.string().nullable().default(null).describe('Currency the index is traded in.'), +}).passthrough() + +export type AvailableIndicesData = z.infer diff --git a/packages/opentypebb/src/standard-models/balance-of-payments.ts b/packages/opentypebb/src/standard-models/balance-of-payments.ts new file mode 100644 index 00000000..9b7219a5 --- /dev/null +++ b/packages/opentypebb/src/standard-models/balance-of-payments.ts @@ -0,0 +1,27 @@ +/** + * Balance of Payments Standard Model. + * Maps to: openbb_core/provider/standard_models/balance_of_payments.py + * + * Note: Python defines multiple data classes (BP6BopUsdData, ECBMain, ECBSummary, etc.) + * for different provider report types. In TypeScript we define a generic base schema + * and let provider-specific fetchers extend with their own fields via .passthrough(). + */ + +import { z } from 'zod' + +export const BalanceOfPaymentsQueryParamsSchema = z.object({}).passthrough() + +export type BalanceOfPaymentsQueryParams = z.infer + +export const BalanceOfPaymentsDataSchema = z.object({ + period: z.string().nullable().default(null).describe('The date representing the beginning of the reporting period.'), + current_account: z.number().nullable().default(null).describe('Current Account Balance.'), + goods: z.number().nullable().default(null).describe('Goods Balance.'), + services: z.number().nullable().default(null).describe('Services Balance.'), + primary_income: z.number().nullable().default(null).describe('Primary Income Balance.'), + secondary_income: z.number().nullable().default(null).describe('Secondary Income Balance.'), + capital_account: z.number().nullable().default(null).describe('Capital Account Balance.'), + financial_account: z.number().nullable().default(null).describe('Financial Account Balance.'), +}).passthrough() + +export type BalanceOfPaymentsData = z.infer diff --git a/packages/opentypebb/src/standard-models/balance-sheet-growth.ts b/packages/opentypebb/src/standard-models/balance-sheet-growth.ts new file mode 100644 index 00000000..8f53ba8a --- /dev/null +++ b/packages/opentypebb/src/standard-models/balance-sheet-growth.ts @@ -0,0 +1,25 @@ +/** + * Balance Sheet Growth Standard Model. + * Maps to: standard_models/balance_sheet_growth.py + */ + +import { z } from 'zod' + +// --- Query Params --- + +export const BalanceSheetGrowthQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), + limit: z.coerce.number().int().nullable().default(null).describe('The number of data entries to return.'), +}) + +export type BalanceSheetGrowthQueryParams = z.infer + +// --- Data --- + +export const BalanceSheetGrowthDataSchema = z.object({ + period_ending: z.string().describe('The end date of the reporting period.'), + fiscal_period: z.string().nullable().default(null).describe('The fiscal period of the report.'), + fiscal_year: z.coerce.number().int().nullable().default(null).describe('The fiscal year of the fiscal period.'), +}).passthrough() + +export type BalanceSheetGrowthData = z.infer diff --git a/packages/opentypebb/src/standard-models/balance-sheet.ts b/packages/opentypebb/src/standard-models/balance-sheet.ts new file mode 100644 index 00000000..6c4169a5 --- /dev/null +++ b/packages/opentypebb/src/standard-models/balance-sheet.ts @@ -0,0 +1,21 @@ +/** + * Balance Sheet Standard Model. + * Maps to: openbb_core/provider/standard_models/balance_sheet.py + */ + +import { z } from 'zod' + +export const BalanceSheetQueryParamsSchema = z.object({ + symbol: z.string().transform((v) => v.toUpperCase()), + limit: z.number().int().nonnegative().nullable().default(null).describe('The number of data entries to return.'), +}).passthrough() + +export type BalanceSheetQueryParams = z.infer + +export const BalanceSheetDataSchema = z.object({ + period_ending: z.string().describe('The end date of the reporting period.'), + fiscal_period: z.string().nullable().default(null).describe('The fiscal period of the report.'), + fiscal_year: z.number().int().nullable().default(null).describe('The fiscal year of the fiscal period.'), +}).passthrough() + +export type BalanceSheetData = z.infer diff --git a/packages/opentypebb/src/standard-models/bls-search.ts b/packages/opentypebb/src/standard-models/bls-search.ts new file mode 100644 index 00000000..85ed0c54 --- /dev/null +++ b/packages/opentypebb/src/standard-models/bls-search.ts @@ -0,0 +1,20 @@ +/** + * BLS Search Standard Model. + */ + +import { z } from 'zod' + +export const BlsSearchQueryParamsSchema = z.object({ + query: z.string().describe('Search query for BLS series.'), + limit: z.number().default(50).describe('Maximum number of results.'), +}).passthrough() + +export type BlsSearchQueryParams = z.infer + +export const BlsSearchDataSchema = z.object({ + series_id: z.string().describe('BLS series ID.'), + title: z.string().nullable().default(null).describe('Series title.'), + survey_abbreviation: z.string().nullable().default(null).describe('Survey abbreviation.'), +}).passthrough() + +export type BlsSearchData = z.infer diff --git a/packages/opentypebb/src/standard-models/bls-series.ts b/packages/opentypebb/src/standard-models/bls-series.ts new file mode 100644 index 00000000..a9250f71 --- /dev/null +++ b/packages/opentypebb/src/standard-models/bls-series.ts @@ -0,0 +1,22 @@ +/** + * BLS Series Standard Model. + */ + +import { z } from 'zod' + +export const BlsSeriesQueryParamsSchema = z.object({ + symbol: z.string().describe('BLS series ID(s), comma-separated for multiple.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type BlsSeriesQueryParams = z.infer + +export const BlsSeriesDataSchema = z.object({ + date: z.string().describe('Observation date.'), + series_id: z.string().nullable().default(null).describe('BLS series identifier.'), + value: z.number().nullable().default(null).describe('Observation value.'), + period: z.string().nullable().default(null).describe('BLS period code (e.g., M01).'), +}).passthrough() + +export type BlsSeriesData = z.infer diff --git a/packages/opentypebb/src/standard-models/calendar-dividend.ts b/packages/opentypebb/src/standard-models/calendar-dividend.ts new file mode 100644 index 00000000..5b980f66 --- /dev/null +++ b/packages/opentypebb/src/standard-models/calendar-dividend.ts @@ -0,0 +1,29 @@ +/** + * Dividend Calendar Standard Model. + * Maps to: standard_models/calendar_dividend.py + */ + +import { z } from 'zod' + +// --- Query Params --- + +export const CalendarDividendQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), +}) + +export type CalendarDividendQueryParams = z.infer + +// --- Data --- + +export const CalendarDividendDataSchema = z.object({ + ex_dividend_date: z.string().describe('The ex-dividend date.'), + symbol: z.string().describe('Symbol representing the entity.'), + amount: z.number().nullable().default(null).describe('The dividend amount per share.'), + name: z.string().nullable().default(null).describe('Name of the entity.'), + record_date: z.string().nullable().default(null).describe('The record date of ownership for eligibility.'), + payment_date: z.string().nullable().default(null).describe('The payment date of the dividend.'), + declaration_date: z.string().nullable().default(null).describe('Declaration date of the dividend.'), +}).passthrough() + +export type CalendarDividendData = z.infer diff --git a/packages/opentypebb/src/standard-models/calendar-earnings.ts b/packages/opentypebb/src/standard-models/calendar-earnings.ts new file mode 100644 index 00000000..9c9f321f --- /dev/null +++ b/packages/opentypebb/src/standard-models/calendar-earnings.ts @@ -0,0 +1,23 @@ +/** + * Earnings Calendar Standard Model. + * Maps to: openbb_core/provider/standard_models/calendar_earnings.py + */ + +import { z } from 'zod' + +export const CalendarEarningsQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), +}).passthrough() + +export type CalendarEarningsQueryParams = z.infer + +export const CalendarEarningsDataSchema = z.object({ + report_date: z.string().describe('The date of the earnings report.'), + symbol: z.string().describe('Symbol representing the entity requested in the data.'), + name: z.string().nullable().default(null).describe('Name of the entity.'), + eps_previous: z.number().nullable().default(null).describe('The earnings-per-share from the same previously reported period.'), + eps_consensus: z.number().nullable().default(null).describe('The analyst consensus earnings-per-share estimate.'), +}).passthrough() + +export type CalendarEarningsData = z.infer diff --git a/packages/opentypebb/src/standard-models/calendar-ipo.ts b/packages/opentypebb/src/standard-models/calendar-ipo.ts new file mode 100644 index 00000000..5889b99e --- /dev/null +++ b/packages/opentypebb/src/standard-models/calendar-ipo.ts @@ -0,0 +1,26 @@ +/** + * IPO Calendar Standard Model. + * Maps to: standard_models/calendar_ipo.py + */ + +import { z } from 'zod' + +// --- Query Params --- + +export const CalendarIpoQueryParamsSchema = z.object({ + symbol: z.string().nullable().default(null).describe('Symbol to get data for.'), + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), + limit: z.coerce.number().int().nullable().default(100).describe('The number of data entries to return.'), +}) + +export type CalendarIpoQueryParams = z.infer + +// --- Data --- + +export const CalendarIpoDataSchema = z.object({ + symbol: z.string().nullable().default(null).describe('Symbol representing the entity.'), + ipo_date: z.string().nullable().default(null).describe('The date of the IPO.'), +}).passthrough() + +export type CalendarIpoData = z.infer diff --git a/packages/opentypebb/src/standard-models/calendar-splits.ts b/packages/opentypebb/src/standard-models/calendar-splits.ts new file mode 100644 index 00000000..d045f114 --- /dev/null +++ b/packages/opentypebb/src/standard-models/calendar-splits.ts @@ -0,0 +1,26 @@ +/** + * Calendar Splits Standard Model. + * Maps to: standard_models/calendar_splits.py + */ + +import { z } from 'zod' + +// --- Query Params --- + +export const CalendarSplitsQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), +}) + +export type CalendarSplitsQueryParams = z.infer + +// --- Data --- + +export const CalendarSplitsDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + symbol: z.string().describe('Symbol representing the entity.'), + numerator: z.number().describe('Numerator of the stock split.'), + denominator: z.number().describe('Denominator of the stock split.'), +}).passthrough() + +export type CalendarSplitsData = z.infer diff --git a/packages/opentypebb/src/standard-models/cash-flow-growth.ts b/packages/opentypebb/src/standard-models/cash-flow-growth.ts new file mode 100644 index 00000000..9de892ef --- /dev/null +++ b/packages/opentypebb/src/standard-models/cash-flow-growth.ts @@ -0,0 +1,25 @@ +/** + * Cash Flow Statement Growth Standard Model. + * Maps to: standard_models/cash_flow_growth.py + */ + +import { z } from 'zod' + +// --- Query Params --- + +export const CashFlowStatementGrowthQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), + limit: z.coerce.number().int().nullable().default(null).describe('The number of data entries to return.'), +}) + +export type CashFlowStatementGrowthQueryParams = z.infer + +// --- Data --- + +export const CashFlowStatementGrowthDataSchema = z.object({ + period_ending: z.string().describe('The end date of the reporting period.'), + fiscal_period: z.string().nullable().default(null).describe('The fiscal period of the report.'), + fiscal_year: z.coerce.number().int().nullable().default(null).describe('The fiscal year of the fiscal period.'), +}).passthrough() + +export type CashFlowStatementGrowthData = z.infer diff --git a/packages/opentypebb/src/standard-models/cash-flow.ts b/packages/opentypebb/src/standard-models/cash-flow.ts new file mode 100644 index 00000000..0d1a3110 --- /dev/null +++ b/packages/opentypebb/src/standard-models/cash-flow.ts @@ -0,0 +1,21 @@ +/** + * Cash Flow Statement Standard Model. + * Maps to: openbb_core/provider/standard_models/cash_flow_statement.py + */ + +import { z } from 'zod' + +export const CashFlowStatementQueryParamsSchema = z.object({ + symbol: z.string().transform((v) => v.toUpperCase()), + limit: z.number().int().nonnegative().nullable().default(5).describe('The number of data entries to return.'), +}).passthrough() + +export type CashFlowStatementQueryParams = z.infer + +export const CashFlowStatementDataSchema = z.object({ + period_ending: z.string().describe('The end date of the reporting period.'), + fiscal_period: z.string().nullable().default(null).describe('The fiscal period of the report.'), + fiscal_year: z.number().int().nullable().default(null).describe('The fiscal year of the fiscal period.'), +}).passthrough() + +export type CashFlowStatementData = z.infer diff --git a/packages/opentypebb/src/standard-models/central-bank-holdings.ts b/packages/opentypebb/src/standard-models/central-bank-holdings.ts new file mode 100644 index 00000000..329c55ed --- /dev/null +++ b/packages/opentypebb/src/standard-models/central-bank-holdings.ts @@ -0,0 +1,18 @@ +/** + * Central Bank Holdings Standard Model. + * Maps to: openbb_core/provider/standard_models/central_bank_holdings.py + */ + +import { z } from 'zod' + +export const CentralBankHoldingsQueryParamsSchema = z.object({ + date: z.string().nullable().default(null).describe('A specific date to get data for.'), +}).passthrough() + +export type CentralBankHoldingsQueryParams = z.infer + +export const CentralBankHoldingsDataSchema = z.object({ + date: z.string().describe('The date of the data.'), +}).passthrough() + +export type CentralBankHoldingsData = z.infer diff --git a/packages/opentypebb/src/standard-models/chokepoint-info.ts b/packages/opentypebb/src/standard-models/chokepoint-info.ts new file mode 100644 index 00000000..f6e2392b --- /dev/null +++ b/packages/opentypebb/src/standard-models/chokepoint-info.ts @@ -0,0 +1,21 @@ +/** + * Chokepoint Info Standard Model (Stub). + */ + +import { z } from 'zod' + +export const ChokepointInfoQueryParamsSchema = z.object({ + chokepoint: z.string().default('').describe('Chokepoint name (e.g., "Suez Canal", "Strait of Hormuz").'), +}).passthrough() + +export type ChokepointInfoQueryParams = z.infer + +export const ChokepointInfoDataSchema = z.object({ + name: z.string().nullable().default(null).describe('Chokepoint name.'), + region: z.string().nullable().default(null).describe('Geographic region.'), + latitude: z.number().nullable().default(null).describe('Latitude.'), + longitude: z.number().nullable().default(null).describe('Longitude.'), + description: z.string().nullable().default(null).describe('Description.'), +}).passthrough() + +export type ChokepointInfoData = z.infer diff --git a/packages/opentypebb/src/standard-models/chokepoint-volume.ts b/packages/opentypebb/src/standard-models/chokepoint-volume.ts new file mode 100644 index 00000000..23a67208 --- /dev/null +++ b/packages/opentypebb/src/standard-models/chokepoint-volume.ts @@ -0,0 +1,22 @@ +/** + * Chokepoint Volume Standard Model (Stub). + */ + +import { z } from 'zod' + +export const ChokepointVolumeQueryParamsSchema = z.object({ + chokepoint: z.string().default('').describe('Chokepoint name.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type ChokepointVolumeQueryParams = z.infer + +export const ChokepointVolumeDataSchema = z.object({ + date: z.string().describe('Observation date.'), + chokepoint: z.string().nullable().default(null).describe('Chokepoint name.'), + volume: z.number().nullable().default(null).describe('Transit volume.'), + unit: z.string().nullable().default(null).describe('Unit of measurement.'), +}).passthrough() + +export type ChokepointVolumeData = z.infer diff --git a/packages/opentypebb/src/standard-models/commodity-spot-price.ts b/packages/opentypebb/src/standard-models/commodity-spot-price.ts new file mode 100644 index 00000000..79fd4fc9 --- /dev/null +++ b/packages/opentypebb/src/standard-models/commodity-spot-price.ts @@ -0,0 +1,25 @@ +/** + * Commodity Spot Price Standard Model. + */ + +import { z } from 'zod' + +export const CommoditySpotPriceQueryParamsSchema = z.object({ + symbol: z.string().describe('Commodity futures symbol(s), comma-separated (e.g., "GC=F,CL=F,SI=F").'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type CommoditySpotPriceQueryParams = z.infer + +export const CommoditySpotPriceDataSchema = z.object({ + date: z.string().describe('Trade date.'), + symbol: z.string().nullable().default(null).describe('Commodity symbol.'), + open: z.number().nullable().default(null).describe('Opening price.'), + high: z.number().nullable().default(null).describe('High price.'), + low: z.number().nullable().default(null).describe('Low price.'), + close: z.number().nullable().default(null).describe('Closing price.'), + volume: z.number().nullable().default(null).describe('Trade volume.'), +}).passthrough() + +export type CommoditySpotPriceData = z.infer diff --git a/packages/opentypebb/src/standard-models/company-filings.ts b/packages/opentypebb/src/standard-models/company-filings.ts new file mode 100644 index 00000000..5f732996 --- /dev/null +++ b/packages/opentypebb/src/standard-models/company-filings.ts @@ -0,0 +1,18 @@ +/** + * Company Filings Standard Model. + * Maps to: standard_models/company_filings.py + */ + +import { z } from 'zod' + +export const CompanyFilingsQueryParamsSchema = z.object({ + symbol: z.string().nullable().default(null).transform(v => v ? v.toUpperCase() : null).describe('Symbol to get data for.'), +}) +export type CompanyFilingsQueryParams = z.infer + +export const CompanyFilingsDataSchema = z.object({ + filing_date: z.string().describe('The date of the filing.'), + report_type: z.string().nullable().default(null).describe('Type of filing.'), + report_url: z.string().describe('URL to the filing.'), +}).passthrough() +export type CompanyFilingsData = z.infer diff --git a/packages/opentypebb/src/standard-models/company-news.ts b/packages/opentypebb/src/standard-models/company-news.ts new file mode 100644 index 00000000..fe909c14 --- /dev/null +++ b/packages/opentypebb/src/standard-models/company-news.ts @@ -0,0 +1,28 @@ +/** + * Company News Standard Model. + * Maps to: openbb_core/provider/standard_models/company_news.py + */ + +import { z } from 'zod' + +export const CompanyNewsQueryParamsSchema = z.object({ + symbol: z.string().nullable().default(null).transform((v) => v?.toUpperCase() ?? null), + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), + limit: z.number().int().nonnegative().nullable().default(null).describe('The number of data entries to return.'), +}).passthrough() + +export type CompanyNewsQueryParams = z.infer + +export const CompanyNewsDataSchema = z.object({ + date: z.string().nullable().default(null).describe('The date of publication.'), + title: z.string().describe('Title of the article.'), + author: z.string().nullable().default(null).describe('Author of the article.'), + excerpt: z.string().nullable().default(null).describe('Excerpt of the article text.'), + body: z.string().nullable().default(null).describe('Body of the article text.'), + images: z.unknown().nullable().default(null).describe('Images associated with the article.'), + url: z.string().describe('URL to the article.'), + symbols: z.string().nullable().default(null).describe('Symbols associated with the article.'), +}).passthrough() + +export type CompanyNewsData = z.infer diff --git a/packages/opentypebb/src/standard-models/composite-leading-indicator.ts b/packages/opentypebb/src/standard-models/composite-leading-indicator.ts new file mode 100644 index 00000000..fc180936 --- /dev/null +++ b/packages/opentypebb/src/standard-models/composite-leading-indicator.ts @@ -0,0 +1,21 @@ +/** + * Composite Leading Indicator Standard Model. + * Maps to: openbb_core/provider/standard_models/composite_leading_indicator.py + */ + +import { z } from 'zod' + +export const CompositeLeadingIndicatorQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), +}).passthrough() + +export type CompositeLeadingIndicatorQueryParams = z.infer + +export const CompositeLeadingIndicatorDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + value: z.number().nullable().default(null).describe('CLI value.'), + country: z.string().describe('Country for the CLI value.'), +}).passthrough() + +export type CompositeLeadingIndicatorData = z.infer diff --git a/packages/opentypebb/src/standard-models/consumer-price-index.ts b/packages/opentypebb/src/standard-models/consumer-price-index.ts new file mode 100644 index 00000000..8c894ec6 --- /dev/null +++ b/packages/opentypebb/src/standard-models/consumer-price-index.ts @@ -0,0 +1,25 @@ +/** + * Consumer Price Index Standard Model. + * Maps to: openbb_core/provider/standard_models/consumer_price_index.py + */ + +import { z } from 'zod' + +export const ConsumerPriceIndexQueryParamsSchema = z.object({ + country: z.string().default('united_states').describe('The country to get data for.'), + transform: z.string().default('yoy').describe('Transformation of the CPI data.'), + frequency: z.enum(['annual', 'quarter', 'monthly']).default('monthly').describe('The frequency of the data.'), + harmonized: z.boolean().default(false).describe('If true, returns harmonized data.'), + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), +}).passthrough() + +export type ConsumerPriceIndexQueryParams = z.infer + +export const ConsumerPriceIndexDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + country: z.string().describe('The country.'), + value: z.number().describe('CPI index value or period change.'), +}).passthrough() + +export type ConsumerPriceIndexData = z.infer diff --git a/packages/opentypebb/src/standard-models/country-interest-rates.ts b/packages/opentypebb/src/standard-models/country-interest-rates.ts new file mode 100644 index 00000000..e5c8e746 --- /dev/null +++ b/packages/opentypebb/src/standard-models/country-interest-rates.ts @@ -0,0 +1,22 @@ +/** + * Country Interest Rates Standard Model. + * Maps to: openbb_core/provider/standard_models/country_interest_rates.py + */ + +import { z } from 'zod' + +export const CountryInterestRatesQueryParamsSchema = z.object({ + country: z.string().default('united_states').describe('The country to get data for.'), + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), +}).passthrough() + +export type CountryInterestRatesQueryParams = z.infer + +export const CountryInterestRatesDataSchema = z.object({ + date: z.string().nullable().default(null).describe('The date of the data.'), + value: z.number().nullable().default(null).describe('The interest rate value.'), + country: z.string().nullable().default(null).describe('Country for which the interest rate is given.'), +}).passthrough() + +export type CountryInterestRatesData = z.infer diff --git a/packages/opentypebb/src/standard-models/country-profile.ts b/packages/opentypebb/src/standard-models/country-profile.ts new file mode 100644 index 00000000..fe1e83ae --- /dev/null +++ b/packages/opentypebb/src/standard-models/country-profile.ts @@ -0,0 +1,31 @@ +/** + * Country Profile Standard Model. + * Maps to: openbb_core/provider/standard_models/country_profile.py + */ + +import { z } from 'zod' + +export const CountryProfileQueryParamsSchema = z.object({ + country: z.string().transform(v => v.toLowerCase().replace(/ /g, '_')).describe('The country to get data for.'), +}).passthrough() + +export type CountryProfileQueryParams = z.infer + +export const CountryProfileDataSchema = z.object({ + country: z.string().describe('The country.'), + population: z.number().nullable().default(null).describe('Population.'), + gdp_usd: z.number().nullable().default(null).describe('Gross Domestic Product, in billions of USD.'), + gdp_qoq: z.number().nullable().default(null).describe('GDP growth quarter-over-quarter change.'), + gdp_yoy: z.number().nullable().default(null).describe('GDP growth year-over-year change.'), + cpi_yoy: z.number().nullable().default(null).describe('Consumer Price Index year-over-year change.'), + core_yoy: z.number().nullable().default(null).describe('Core Consumer Price Index year-over-year change.'), + retail_sales_yoy: z.number().nullable().default(null).describe('Retail Sales year-over-year change.'), + industrial_production_yoy: z.number().nullable().default(null).describe('Industrial Production year-over-year change.'), + policy_rate: z.number().nullable().default(null).describe('Short term policy rate.'), + yield_10y: z.number().nullable().default(null).describe('10-year government bond yield.'), + govt_debt_gdp: z.number().nullable().default(null).describe('Government debt as percent of GDP.'), + current_account_gdp: z.number().nullable().default(null).describe('Current account balance as percent of GDP.'), + jobless_rate: z.number().nullable().default(null).describe('Unemployment rate.'), +}).passthrough() + +export type CountryProfileData = z.infer diff --git a/packages/opentypebb/src/standard-models/crypto-historical.ts b/packages/opentypebb/src/standard-models/crypto-historical.ts new file mode 100644 index 00000000..41e82160 --- /dev/null +++ b/packages/opentypebb/src/standard-models/crypto-historical.ts @@ -0,0 +1,26 @@ +/** + * Crypto Historical Price Standard Model. + * Maps to: openbb_core/provider/standard_models/crypto_historical.py + */ + +import { z } from 'zod' + +export const CryptoHistoricalQueryParamsSchema = z.object({ + symbol: z.string().transform((v) => v.toUpperCase()), + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), +}).passthrough() + +export type CryptoHistoricalQueryParams = z.infer + +export const CryptoHistoricalDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + open: z.number().nullable().default(null).describe('The open price.'), + high: z.number().nullable().default(null).describe('The high price.'), + low: z.number().nullable().default(null).describe('The low price.'), + close: z.number().describe('The close price.'), + volume: z.number().nullable().default(null).describe('The trading volume.'), + vwap: z.number().nullable().default(null).describe('Volume Weighted Average Price over the period.'), +}).passthrough() + +export type CryptoHistoricalData = z.infer diff --git a/packages/opentypebb/src/standard-models/crypto-search.ts b/packages/opentypebb/src/standard-models/crypto-search.ts new file mode 100644 index 00000000..bcb54017 --- /dev/null +++ b/packages/opentypebb/src/standard-models/crypto-search.ts @@ -0,0 +1,19 @@ +/** + * Crypto Search Standard Model. + * Maps to: openbb_core/provider/standard_models/crypto_search.py + */ + +import { z } from 'zod' + +export const CryptoSearchQueryParamsSchema = z.object({ + query: z.string().nullable().default(null).describe('Search query.'), +}).passthrough() + +export type CryptoSearchQueryParams = z.infer + +export const CryptoSearchDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity requested in the data. (Crypto)'), + name: z.string().nullable().default(null).describe('Name of the crypto.'), +}).passthrough() + +export type CryptoSearchData = z.infer diff --git a/packages/opentypebb/src/standard-models/currency-historical.ts b/packages/opentypebb/src/standard-models/currency-historical.ts new file mode 100644 index 00000000..0665b970 --- /dev/null +++ b/packages/opentypebb/src/standard-models/currency-historical.ts @@ -0,0 +1,26 @@ +/** + * Currency Historical Price Standard Model. + * Maps to: openbb_core/provider/standard_models/currency_historical.py + */ + +import { z } from 'zod' + +export const CurrencyHistoricalQueryParamsSchema = z.object({ + symbol: z.string().transform((v) => v.toUpperCase().replace(/-/g, '')), + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), +}).passthrough() + +export type CurrencyHistoricalQueryParams = z.infer + +export const CurrencyHistoricalDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + open: z.number().nullable().default(null).describe('The open price.'), + high: z.number().nullable().default(null).describe('The high price.'), + low: z.number().nullable().default(null).describe('The low price.'), + close: z.number().describe('The close price.'), + volume: z.number().nullable().default(null).describe('The trading volume.'), + vwap: z.number().nullable().default(null).describe('Volume Weighted Average Price over the period.'), +}).passthrough() + +export type CurrencyHistoricalData = z.infer diff --git a/packages/opentypebb/src/standard-models/currency-pairs.ts b/packages/opentypebb/src/standard-models/currency-pairs.ts new file mode 100644 index 00000000..eba2e110 --- /dev/null +++ b/packages/opentypebb/src/standard-models/currency-pairs.ts @@ -0,0 +1,19 @@ +/** + * Currency Available Pairs Standard Model. + * Maps to: openbb_core/provider/standard_models/currency_pairs.py + */ + +import { z } from 'zod' + +export const CurrencyPairsQueryParamsSchema = z.object({ + query: z.string().nullable().default(null).describe('Query to search for currency pairs.'), +}).passthrough() + +export type CurrencyPairsQueryParams = z.infer + +export const CurrencyPairsDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity requested in the data.'), + name: z.string().nullable().default(null).describe('Name of the currency pair.'), +}).passthrough() + +export type CurrencyPairsData = z.infer diff --git a/packages/opentypebb/src/standard-models/currency-snapshots.ts b/packages/opentypebb/src/standard-models/currency-snapshots.ts new file mode 100644 index 00000000..e9a4c9e0 --- /dev/null +++ b/packages/opentypebb/src/standard-models/currency-snapshots.ts @@ -0,0 +1,30 @@ +/** + * Currency Snapshots Standard Model. + * Maps to: openbb_core/provider/standard_models/currency_snapshots.py + */ + +import { z } from 'zod' + +export const CurrencySnapshotsQueryParamsSchema = z.object({ + base: z.string().default('usd').describe('The base currency symbol.'), + quote_type: z.enum(['direct', 'indirect']).default('indirect').describe('Whether the quote is direct or indirect.'), + counter_currencies: z.string().nullable().default(null).describe('An optional comma-separated list of counter currency symbols to filter for.'), +}).passthrough() + +export type CurrencySnapshotsQueryParams = z.infer + +const numOrNull = z.number().nullable().default(null) + +export const CurrencySnapshotsDataSchema = z.object({ + base_currency: z.string().describe('The base, or domestic, currency.'), + counter_currency: z.string().describe('The counter, or foreign, currency.'), + last_rate: z.number().describe('The exchange rate, relative to the base currency.'), + open: numOrNull.describe('Opening price.'), + high: numOrNull.describe('High price.'), + low: numOrNull.describe('Low price.'), + close: numOrNull.describe('Close price.'), + volume: numOrNull.describe('Trading volume.'), + prev_close: numOrNull.describe('Previous close price.'), +}).passthrough() + +export type CurrencySnapshotsData = z.infer diff --git a/packages/opentypebb/src/standard-models/direction-of-trade.ts b/packages/opentypebb/src/standard-models/direction-of-trade.ts new file mode 100644 index 00000000..494834af --- /dev/null +++ b/packages/opentypebb/src/standard-models/direction-of-trade.ts @@ -0,0 +1,29 @@ +/** + * Direction of Trade Standard Model. + * Maps to: openbb_core/provider/standard_models/direction_of_trade.py + */ + +import { z } from 'zod' + +export const DirectionOfTradeQueryParamsSchema = z.object({ + country: z.string().nullable().default(null).describe('The country to get data for. None is equivalent to all.'), + counterpart: z.string().nullable().default(null).describe('Counterpart country to the trade.'), + direction: z.enum(['exports', 'imports', 'balance', 'all']).default('balance').describe('Trade direction.'), + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), + frequency: z.enum(['month', 'quarter', 'annual']).default('month').describe('The frequency of the data.'), +}).passthrough() + +export type DirectionOfTradeQueryParams = z.infer + +export const DirectionOfTradeDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + symbol: z.string().nullable().default(null).describe('Symbol representing the entity.'), + country: z.string().describe('The country.'), + counterpart: z.string().describe('Counterpart country or region to the trade.'), + title: z.string().nullable().default(null).describe('Title corresponding to the symbol.'), + value: z.number().describe('Trade value.'), + scale: z.string().nullable().default(null).describe('Scale of the value.'), +}).passthrough() + +export type DirectionOfTradeData = z.infer diff --git a/packages/opentypebb/src/standard-models/discovery-filings.ts b/packages/opentypebb/src/standard-models/discovery-filings.ts new file mode 100644 index 00000000..2d65d253 --- /dev/null +++ b/packages/opentypebb/src/standard-models/discovery-filings.ts @@ -0,0 +1,26 @@ +/** + * Discovery Filings Standard Model. + * Maps to: openbb_core/provider/standard_models/discovery_filings.py + */ + +import { z } from 'zod' + +export const DiscoveryFilingsQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), + form_type: z.string().nullable().default(null).describe('Filter by form type.'), + limit: z.coerce.number().nullable().default(null).describe('The number of data entries to return.'), +}).passthrough() + +export type DiscoveryFilingsQueryParams = z.infer + +export const DiscoveryFilingsDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity.'), + cik: z.string().describe('CIK number.'), + filing_date: z.string().describe('The filing date.'), + accepted_date: z.string().describe('The accepted date.'), + form_type: z.string().describe('The form type of the filing.'), + link: z.string().describe('URL to the filing page on the SEC site.'), +}).passthrough() + +export type DiscoveryFilingsData = z.infer diff --git a/packages/opentypebb/src/standard-models/earnings-call-transcript.ts b/packages/opentypebb/src/standard-models/earnings-call-transcript.ts new file mode 100644 index 00000000..e26e96b7 --- /dev/null +++ b/packages/opentypebb/src/standard-models/earnings-call-transcript.ts @@ -0,0 +1,24 @@ +/** + * Earnings Call Transcript Standard Model. + * Maps to: openbb_core/provider/standard_models/earnings_call_transcript.py + */ + +import { z } from 'zod' + +export const EarningsCallTranscriptQueryParamsSchema = z.object({ + symbol: z.string().describe('Symbol to get data for.'), + year: z.coerce.number().nullable().default(null).describe('Year of the earnings call transcript.'), + quarter: z.coerce.number().nullable().default(null).describe('Quarterly period of the earnings call transcript (1-4).'), +}).passthrough() + +export type EarningsCallTranscriptQueryParams = z.infer + +export const EarningsCallTranscriptDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity.'), + year: z.number().describe('Year of the earnings call transcript.'), + quarter: z.string().describe('Quarter of the earnings call transcript.'), + date: z.string().describe('The date of the data.'), + content: z.string().describe('Content of the earnings call transcript.'), +}).passthrough() + +export type EarningsCallTranscriptData = z.infer diff --git a/packages/opentypebb/src/standard-models/economic-calendar.ts b/packages/opentypebb/src/standard-models/economic-calendar.ts new file mode 100644 index 00000000..a599942b --- /dev/null +++ b/packages/opentypebb/src/standard-models/economic-calendar.ts @@ -0,0 +1,34 @@ +/** + * Economic Calendar Standard Model. + * Maps to: standard_models/economic_calendar.py + */ + +import { z } from 'zod' + +// --- Query Params --- + +export const EconomicCalendarQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), +}) + +export type EconomicCalendarQueryParams = z.infer + +// --- Data --- + +export const EconomicCalendarDataSchema = z.object({ + date: z.string().nullable().default(null).describe('The date of the data.'), + country: z.string().nullable().default(null).describe('Country of event.'), + category: z.string().nullable().default(null).describe('Category of event.'), + event: z.string().nullable().default(null).describe('Event name.'), + importance: z.string().nullable().default(null).describe('The importance level for the event.'), + source: z.string().nullable().default(null).describe('Source of the data.'), + currency: z.string().nullable().default(null).describe('Currency of the data.'), + unit: z.string().nullable().default(null).describe('Unit of the data.'), + consensus: z.union([z.string(), z.number()]).nullable().default(null).describe('Average forecast among a representative group of economists.'), + previous: z.union([z.string(), z.number()]).nullable().default(null).describe('Value for the previous period after the revision.'), + revised: z.union([z.string(), z.number()]).nullable().default(null).describe('Revised previous value, if applicable.'), + actual: z.union([z.string(), z.number()]).nullable().default(null).describe('Latest released value.'), +}).passthrough() + +export type EconomicCalendarData = z.infer diff --git a/packages/opentypebb/src/standard-models/economic-conditions-chicago.ts b/packages/opentypebb/src/standard-models/economic-conditions-chicago.ts new file mode 100644 index 00000000..1f1630c0 --- /dev/null +++ b/packages/opentypebb/src/standard-models/economic-conditions-chicago.ts @@ -0,0 +1,21 @@ +/** + * Chicago Fed National Activity Index Standard Model. + * Maps to: openbb_core/provider/standard_models/economic_conditions_chicago.py + */ + +import { z } from 'zod' + +export const EconomicConditionsChicagoQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type EconomicConditionsChicagoQueryParams = z.infer + +export const EconomicConditionsChicagoDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + cfnai: z.number().nullable().default(null).describe('Chicago Fed National Activity Index.'), + cfnai_ma3: z.number().nullable().default(null).describe('CFNAI 3-month moving average.'), +}).passthrough() + +export type EconomicConditionsChicagoData = z.infer diff --git a/packages/opentypebb/src/standard-models/economic-indicators.ts b/packages/opentypebb/src/standard-models/economic-indicators.ts new file mode 100644 index 00000000..2478de30 --- /dev/null +++ b/packages/opentypebb/src/standard-models/economic-indicators.ts @@ -0,0 +1,26 @@ +/** + * Economic Indicators Standard Model. + * Maps to: openbb_core/provider/standard_models/economic_indicators.py + */ + +import { z } from 'zod' + +export const EconomicIndicatorsQueryParamsSchema = z.object({ + symbol: z.string().describe('Symbol to get data for.'), + country: z.string().nullable().default(null).describe('The country to get data for.'), + frequency: z.string().nullable().default(null).describe('The frequency of the data.'), + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), +}).passthrough() + +export type EconomicIndicatorsQueryParams = z.infer + +export const EconomicIndicatorsDataSchema = z.object({ + date: z.string().nullable().default(null).describe('The date of the data.'), + symbol_root: z.string().nullable().default(null).describe('The root symbol for the indicator (e.g. GDP).'), + symbol: z.string().nullable().default(null).describe('Symbol representing the entity.'), + country: z.string().nullable().default(null).describe('The country represented by the data.'), + value: z.number().nullable().default(null).describe('The value of the indicator.'), +}).passthrough() + +export type EconomicIndicatorsData = z.infer diff --git a/packages/opentypebb/src/standard-models/equity-discovery.ts b/packages/opentypebb/src/standard-models/equity-discovery.ts new file mode 100644 index 00000000..7c51e212 --- /dev/null +++ b/packages/opentypebb/src/standard-models/equity-discovery.ts @@ -0,0 +1,27 @@ +/** + * Equity Discovery Standard Models (Gainers, Losers, Active). + * Maps to: openbb_core/provider/standard_models/equity_gainers.py (and similar) + * + * Note: In OpenBB Python, equity_gainers.py does not exist as a standard model. + * The gainers/losers/active endpoints are provider-specific. We define a common + * standard model here for TypeScript consistency. + */ + +import { z } from 'zod' + +export const EquityDiscoveryQueryParamsSchema = z.object({ + sort: z.string().nullable().default(null).describe('Sort order.'), +}).passthrough() + +export type EquityDiscoveryQueryParams = z.infer + +export const EquityDiscoveryDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity.'), + name: z.string().nullable().default(null).describe('Name of the entity.'), + price: z.number().nullable().default(null).describe('Last price.'), + change: z.number().nullable().default(null).describe('Change in price.'), + percent_change: z.number().nullable().default(null).describe('Percent change in price.'), + volume: z.number().nullable().default(null).describe('Trading volume.'), +}).passthrough() + +export type EquityDiscoveryData = z.infer diff --git a/packages/opentypebb/src/standard-models/equity-historical.ts b/packages/opentypebb/src/standard-models/equity-historical.ts new file mode 100644 index 00000000..64762b77 --- /dev/null +++ b/packages/opentypebb/src/standard-models/equity-historical.ts @@ -0,0 +1,26 @@ +/** + * Equity Historical Price Standard Model. + * Maps to: openbb_core/provider/standard_models/equity_historical.py + */ + +import { z } from 'zod' + +export const EquityHistoricalQueryParamsSchema = z.object({ + symbol: z.string().transform((v) => v.toUpperCase()), + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), +}).passthrough() + +export type EquityHistoricalQueryParams = z.infer + +export const EquityHistoricalDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + open: z.number().describe('The open price.'), + high: z.number().describe('The high price.'), + low: z.number().describe('The low price.'), + close: z.number().describe('The close price.'), + volume: z.number().nullable().default(null).describe('The trading volume.'), + vwap: z.number().nullable().default(null).describe('Volume Weighted Average Price over the period.'), +}).passthrough() + +export type EquityHistoricalData = z.infer diff --git a/packages/opentypebb/src/standard-models/equity-info.ts b/packages/opentypebb/src/standard-models/equity-info.ts new file mode 100644 index 00000000..d69894f1 --- /dev/null +++ b/packages/opentypebb/src/standard-models/equity-info.ts @@ -0,0 +1,55 @@ +/** + * Equity Info Standard Model. + * Maps to: openbb_core/provider/standard_models/equity_info.py + */ + +import { z } from 'zod' + +export const EquityInfoQueryParamsSchema = z.object({ + symbol: z.string().transform((v) => v.toUpperCase()), +}).passthrough() + +export type EquityInfoQueryParams = z.infer + +export const EquityInfoDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity requested in the data.'), + name: z.string().nullable().default(null).describe('Common name of the company.'), + cik: z.string().nullable().default(null).describe('Central Index Key (CIK) for the requested entity.'), + cusip: z.string().nullable().default(null).describe('CUSIP identifier for the company.'), + isin: z.string().nullable().default(null).describe('International Securities Identification Number.'), + lei: z.string().nullable().default(null).describe('Legal Entity Identifier assigned to the company.'), + legal_name: z.string().nullable().default(null).describe('Official legal name of the company.'), + stock_exchange: z.string().nullable().default(null).describe('Stock exchange where the company is traded.'), + sic: z.number().int().nullable().default(null).describe('Standard Industrial Classification code.'), + short_description: z.string().nullable().default(null).describe('Short description of the company.'), + long_description: z.string().nullable().default(null).describe('Long description of the company.'), + ceo: z.string().nullable().default(null).describe('Chief Executive Officer of the company.'), + company_url: z.string().nullable().default(null).describe("URL of the company's website."), + business_address: z.string().nullable().default(null).describe("Address of the company's headquarters."), + mailing_address: z.string().nullable().default(null).describe('Mailing address of the company.'), + business_phone_no: z.string().nullable().default(null).describe("Phone number of the company's headquarters."), + hq_address1: z.string().nullable().default(null).describe("Address of the company's headquarters."), + hq_address2: z.string().nullable().default(null).describe("Address of the company's headquarters."), + hq_address_city: z.string().nullable().default(null).describe("City of the company's headquarters."), + hq_address_postal_code: z.string().nullable().default(null).describe("Zip code of the company's headquarters."), + hq_state: z.string().nullable().default(null).describe("State of the company's headquarters."), + hq_country: z.string().nullable().default(null).describe("Country of the company's headquarters."), + inc_state: z.string().nullable().default(null).describe('State in which the company is incorporated.'), + inc_country: z.string().nullable().default(null).describe('Country in which the company is incorporated.'), + employees: z.number().int().nullable().default(null).describe('Number of employees.'), + entity_legal_form: z.string().nullable().default(null).describe('Legal form of the company.'), + entity_status: z.string().nullable().default(null).describe('Status of the company.'), + latest_filing_date: z.string().nullable().default(null).describe("Date of the company's latest filing."), + irs_number: z.string().nullable().default(null).describe('IRS number assigned to the company.'), + sector: z.string().nullable().default(null).describe('Sector in which the company operates.'), + industry_category: z.string().nullable().default(null).describe('Category of industry.'), + industry_group: z.string().nullable().default(null).describe('Group of industry.'), + template: z.string().nullable().default(null).describe("Template used to standardize the company's financial statements."), + standardized_active: z.boolean().nullable().default(null).describe('Whether the company is active or not.'), + first_fundamental_date: z.string().nullable().default(null).describe("Date of the company's first fundamental."), + last_fundamental_date: z.string().nullable().default(null).describe("Date of the company's last fundamental."), + first_stock_price_date: z.string().nullable().default(null).describe("Date of the company's first stock price."), + last_stock_price_date: z.string().nullable().default(null).describe("Date of the company's last stock price."), +}).passthrough() + +export type EquityInfoData = z.infer diff --git a/packages/opentypebb/src/standard-models/equity-peers.ts b/packages/opentypebb/src/standard-models/equity-peers.ts new file mode 100644 index 00000000..a037b04e --- /dev/null +++ b/packages/opentypebb/src/standard-models/equity-peers.ts @@ -0,0 +1,16 @@ +/** + * Equity Peers Standard Model. + * Maps to: standard_models/equity_peers.py + */ + +import { z } from 'zod' + +export const EquityPeersQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), +}) +export type EquityPeersQueryParams = z.infer + +export const EquityPeersDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity.'), +}).passthrough() +export type EquityPeersData = z.infer diff --git a/packages/opentypebb/src/standard-models/equity-performance.ts b/packages/opentypebb/src/standard-models/equity-performance.ts new file mode 100644 index 00000000..5e7be5bb --- /dev/null +++ b/packages/opentypebb/src/standard-models/equity-performance.ts @@ -0,0 +1,23 @@ +/** + * Equity Performance Standard Model. + * Maps to: openbb_core/provider/standard_models/equity_performance.py + */ + +import { z } from 'zod' + +export const EquityPerformanceQueryParamsSchema = z.object({ + sort: z.enum(['asc', 'desc']).default('desc').describe("Sort order. Possible values: 'asc', 'desc'. Default: 'desc'."), +}).passthrough() + +export type EquityPerformanceQueryParams = z.infer + +export const EquityPerformanceDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity requested in the data.'), + name: z.string().nullable().default(null).describe('Name of the entity.'), + price: z.number().describe('Last price.'), + change: z.number().describe('Change in price.'), + percent_change: z.number().describe('Percent change.'), + volume: z.number().nullable().default(null).describe('Trading volume.'), +}).passthrough() + +export type EquityPerformanceData = z.infer diff --git a/packages/opentypebb/src/standard-models/equity-quote.ts b/packages/opentypebb/src/standard-models/equity-quote.ts new file mode 100644 index 00000000..ca70e0df --- /dev/null +++ b/packages/opentypebb/src/standard-models/equity-quote.ts @@ -0,0 +1,38 @@ +/** + * Equity Quote Standard Model. + * Maps to: openbb_core/provider/standard_models/equity_quote.py + */ + +import { z } from 'zod' + +export const EquityQuoteQueryParamsSchema = z.object({ + symbol: z.string().transform((v) => v.toUpperCase()), +}).passthrough() + +export type EquityQuoteQueryParams = z.infer + +export const EquityQuoteDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity requested in the data.'), + asset_type: z.string().nullable().default(null).describe('Type of asset - i.e, stock, ETF, etc.'), + name: z.string().nullable().default(null).describe('Name of the company or asset.'), + exchange: z.string().nullable().default(null).describe('The name or symbol of the venue where the data is from.'), + bid: z.number().nullable().default(null).describe('Price of the top bid order.'), + bid_size: z.number().int().nullable().default(null).describe('Number of round lot orders at the bid price.'), + ask: z.number().nullable().default(null).describe('Price of the top ask order.'), + ask_size: z.number().int().nullable().default(null).describe('Number of round lot orders at the ask price.'), + last_price: z.number().nullable().default(null).describe('Price of the last trade.'), + last_size: z.number().int().nullable().default(null).describe('Size of the last trade.'), + last_timestamp: z.string().nullable().default(null).describe('Date and Time when the last price was recorded.'), + open: z.number().nullable().default(null).describe('The open price.'), + high: z.number().nullable().default(null).describe('The high price.'), + low: z.number().nullable().default(null).describe('The low price.'), + close: z.number().nullable().default(null).describe('The close price.'), + volume: z.number().nullable().default(null).describe('The trading volume.'), + prev_close: z.number().nullable().default(null).describe('The previous close price.'), + change: z.number().nullable().default(null).describe('Change in price from previous close.'), + change_percent: z.number().nullable().default(null).describe('Change in price as a normalized percentage.'), + year_high: z.number().nullable().default(null).describe('The one year high (52W High).'), + year_low: z.number().nullable().default(null).describe('The one year low (52W Low).'), +}).passthrough() + +export type EquityQuoteData = z.infer diff --git a/packages/opentypebb/src/standard-models/equity-screener.ts b/packages/opentypebb/src/standard-models/equity-screener.ts new file mode 100644 index 00000000..1010ff9a --- /dev/null +++ b/packages/opentypebb/src/standard-models/equity-screener.ts @@ -0,0 +1,15 @@ +/** + * Equity Screener Standard Model. + * Maps to: standard_models/equity_screener.py + */ + +import { z } from 'zod' + +export const EquityScreenerQueryParamsSchema = z.object({}) +export type EquityScreenerQueryParams = z.infer + +export const EquityScreenerDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity.'), + name: z.string().nullable().default(null).describe('Name of the company.'), +}).passthrough() +export type EquityScreenerData = z.infer diff --git a/packages/opentypebb/src/standard-models/esg-score.ts b/packages/opentypebb/src/standard-models/esg-score.ts new file mode 100644 index 00000000..a43a156a --- /dev/null +++ b/packages/opentypebb/src/standard-models/esg-score.ts @@ -0,0 +1,30 @@ +/** + * ESG Score Standard Model. + * Maps to: openbb_core/provider/standard_models/esg.py + */ + +import { z } from 'zod' + +const numOrNull = z.number().nullable().default(null) + +export const EsgScoreQueryParamsSchema = z.object({ + symbol: z.string().describe('Symbol to get data for.'), +}).passthrough() + +export type EsgScoreQueryParams = z.infer + +export const EsgScoreDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity.'), + cik: z.string().nullable().default(null).describe('CIK number.'), + company_name: z.string().nullable().default(null).describe('Company name.'), + form_type: z.string().nullable().default(null).describe('Form type.'), + accepted_date: z.string().nullable().default(null).describe('Accepted date.'), + date: z.string().nullable().default(null).describe('The date of the data.'), + environmental_score: numOrNull.describe('Environmental score.'), + social_score: numOrNull.describe('Social score.'), + governance_score: numOrNull.describe('Governance score.'), + esg_score: numOrNull.describe('ESG score.'), + url: z.string().nullable().default(null).describe('URL to the filing.'), +}).passthrough() + +export type EsgScoreData = z.infer diff --git a/packages/opentypebb/src/standard-models/etf-countries.ts b/packages/opentypebb/src/standard-models/etf-countries.ts new file mode 100644 index 00000000..f28008fa --- /dev/null +++ b/packages/opentypebb/src/standard-models/etf-countries.ts @@ -0,0 +1,18 @@ +/** + * ETF Countries Standard Model. + * Maps to: standard_models/etf_countries.py + */ + +import { z } from 'zod' + +export const EtfCountriesQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), +}) +export type EtfCountriesQueryParams = z.infer + +export const EtfCountriesDataSchema = z.object({ + symbol: z.string().nullable().default(null).describe('Symbol representing the entity.'), + country: z.string().describe('Country of exposure.'), + weight: z.number().describe('Exposure of the ETF to the country in normalized percentage points.'), +}).passthrough() +export type EtfCountriesData = z.infer diff --git a/packages/opentypebb/src/standard-models/etf-equity-exposure.ts b/packages/opentypebb/src/standard-models/etf-equity-exposure.ts new file mode 100644 index 00000000..5c2aebc0 --- /dev/null +++ b/packages/opentypebb/src/standard-models/etf-equity-exposure.ts @@ -0,0 +1,20 @@ +/** + * ETF Equity Exposure Standard Model. + * Maps to: standard_models/etf_equity_exposure.py + */ + +import { z } from 'zod' + +export const EtfEquityExposureQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), +}) +export type EtfEquityExposureQueryParams = z.infer + +export const EtfEquityExposureDataSchema = z.object({ + equity_symbol: z.string().describe('The symbol of the equity.'), + etf_symbol: z.string().describe('The symbol of the ETF.'), + weight: z.number().nullable().default(null).describe('The weight of the equity in the ETF.'), + market_value: z.number().nullable().default(null).describe('The market value of the equity in the ETF.'), + shares: z.number().nullable().default(null).describe('The number of shares held.'), +}).passthrough() +export type EtfEquityExposureData = z.infer diff --git a/packages/opentypebb/src/standard-models/etf-holdings.ts b/packages/opentypebb/src/standard-models/etf-holdings.ts new file mode 100644 index 00000000..782fd922 --- /dev/null +++ b/packages/opentypebb/src/standard-models/etf-holdings.ts @@ -0,0 +1,17 @@ +/** + * ETF Holdings Standard Model. + * Maps to: standard_models/etf_holdings.py + */ + +import { z } from 'zod' + +export const EtfHoldingsQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), +}) +export type EtfHoldingsQueryParams = z.infer + +export const EtfHoldingsDataSchema = z.object({ + symbol: z.string().nullable().default(null).describe('Symbol representing the holding.'), + name: z.string().nullable().default(null).describe('Name of the holding.'), +}).passthrough() +export type EtfHoldingsData = z.infer diff --git a/packages/opentypebb/src/standard-models/etf-info.ts b/packages/opentypebb/src/standard-models/etf-info.ts new file mode 100644 index 00000000..b97def42 --- /dev/null +++ b/packages/opentypebb/src/standard-models/etf-info.ts @@ -0,0 +1,22 @@ +/** + * ETF Info Standard Model. + * Maps to: standard_models/etf_info.py + */ + +import { z } from 'zod' + +export const EtfInfoQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), +}) +export type EtfInfoQueryParams = z.infer + +export const EtfInfoDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity.'), + name: z.string().nullable().default(null).describe('Name of the ETF.'), + issuer: z.string().nullable().default(null).describe('Company of the ETF.'), + domicile: z.string().nullable().default(null).describe('Domicile of the ETF.'), + website: z.string().nullable().default(null).describe('Website of the ETF.'), + description: z.string().nullable().default(null).describe('Description of the ETF.'), + inception_date: z.string().nullable().default(null).describe('Inception date of the ETF.'), +}).passthrough() +export type EtfInfoData = z.infer diff --git a/packages/opentypebb/src/standard-models/etf-search.ts b/packages/opentypebb/src/standard-models/etf-search.ts new file mode 100644 index 00000000..bfd8e721 --- /dev/null +++ b/packages/opentypebb/src/standard-models/etf-search.ts @@ -0,0 +1,17 @@ +/** + * ETF Search Standard Model. + * Maps to: standard_models/etf_search.py + */ + +import { z } from 'zod' + +export const EtfSearchQueryParamsSchema = z.object({ + query: z.string().nullable().default(null).describe('Search query.'), +}) +export type EtfSearchQueryParams = z.infer + +export const EtfSearchDataSchema = z.object({ + symbol: z.string().describe('Symbol of the ETF.'), + name: z.string().nullable().default(null).describe('Name of the ETF.'), +}).passthrough() +export type EtfSearchData = z.infer diff --git a/packages/opentypebb/src/standard-models/etf-sectors.ts b/packages/opentypebb/src/standard-models/etf-sectors.ts new file mode 100644 index 00000000..a0fe7590 --- /dev/null +++ b/packages/opentypebb/src/standard-models/etf-sectors.ts @@ -0,0 +1,18 @@ +/** + * ETF Sectors Standard Model. + * Maps to: standard_models/etf_sectors.py + */ + +import { z } from 'zod' + +export const EtfSectorsQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), +}) +export type EtfSectorsQueryParams = z.infer + +export const EtfSectorsDataSchema = z.object({ + symbol: z.string().nullable().default(null).describe('Symbol representing the entity.'), + sector: z.string().describe('Sector of exposure.'), + weight: z.number().describe('Exposure of the ETF to the sector in normalized percentage points.'), +}).passthrough() +export type EtfSectorsData = z.infer diff --git a/packages/opentypebb/src/standard-models/executive-compensation.ts b/packages/opentypebb/src/standard-models/executive-compensation.ts new file mode 100644 index 00000000..e41c58f4 --- /dev/null +++ b/packages/opentypebb/src/standard-models/executive-compensation.ts @@ -0,0 +1,30 @@ +/** + * Executive Compensation Standard Model. + * Maps to: standard_models/executive_compensation.py + */ + +import { z } from 'zod' + +export const ExecutiveCompensationQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), +}) +export type ExecutiveCompensationQueryParams = z.infer + +const numOrNull = z.number().nullable().default(null) + +export const ExecutiveCompensationDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity.'), + cik: z.string().nullable().default(null).describe('CIK number.'), + report_date: z.string().nullable().default(null).describe('Date of reported compensation.'), + company_name: z.string().nullable().default(null).describe('The name of the company.'), + executive: z.string().nullable().default(null).describe('Name and position.'), + year: z.number().nullable().default(null).describe('Year of the compensation.'), + salary: numOrNull.describe('Base salary.'), + bonus: numOrNull.describe('Bonus payments.'), + stock_award: numOrNull.describe('Stock awards.'), + option_award: numOrNull.describe('Option awards.'), + incentive_plan_compensation: numOrNull.describe('Incentive plan compensation.'), + all_other_compensation: numOrNull.describe('All other compensation.'), + total: numOrNull.describe('Total compensation.'), +}).passthrough() +export type ExecutiveCompensationData = z.infer diff --git a/packages/opentypebb/src/standard-models/export-destinations.ts b/packages/opentypebb/src/standard-models/export-destinations.ts new file mode 100644 index 00000000..aef865d2 --- /dev/null +++ b/packages/opentypebb/src/standard-models/export-destinations.ts @@ -0,0 +1,20 @@ +/** + * Export Destinations Standard Model. + * Maps to: openbb_core/provider/standard_models/export_destinations.py + */ + +import { z } from 'zod' + +export const ExportDestinationsQueryParamsSchema = z.object({ + country: z.string().describe('The country to get data for.'), +}).passthrough() + +export type ExportDestinationsQueryParams = z.infer + +export const ExportDestinationsDataSchema = z.object({ + origin_country: z.string().describe('The country of origin.'), + destination_country: z.string().describe('The destination country.'), + value: z.number().describe('The value of the export.'), +}).passthrough() + +export type ExportDestinationsData = z.infer diff --git a/packages/opentypebb/src/standard-models/financial-ratios.ts b/packages/opentypebb/src/standard-models/financial-ratios.ts new file mode 100644 index 00000000..d267705f --- /dev/null +++ b/packages/opentypebb/src/standard-models/financial-ratios.ts @@ -0,0 +1,22 @@ +/** + * Financial Ratios Standard Model. + * Maps to: openbb_core/provider/standard_models/financial_ratios.py + */ + +import { z } from 'zod' + +export const FinancialRatiosQueryParamsSchema = z.object({ + symbol: z.string().transform((v) => v.toUpperCase()), + limit: z.number().int().nullable().default(null).describe('The number of data entries to return.'), +}).passthrough() + +export type FinancialRatiosQueryParams = z.infer + +export const FinancialRatiosDataSchema = z.object({ + symbol: z.string().nullable().default(null).describe('Symbol representing the entity requested in the data.'), + period_ending: z.string().nullable().default(null).describe('The date of the data.'), + fiscal_period: z.string().nullable().default(null).describe('Period of the financial ratios.'), + fiscal_year: z.number().int().nullable().default(null).describe('Fiscal year.'), +}).passthrough() + +export type FinancialRatiosData = z.infer diff --git a/packages/opentypebb/src/standard-models/fomc-documents.ts b/packages/opentypebb/src/standard-models/fomc-documents.ts new file mode 100644 index 00000000..2368aaf5 --- /dev/null +++ b/packages/opentypebb/src/standard-models/fomc-documents.ts @@ -0,0 +1,22 @@ +/** + * FOMC Documents Standard Model. + * Maps to: openbb_core/provider/standard_models/fomc_documents.py + */ + +import { z } from 'zod' + +export const FomcDocumentsQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type FomcDocumentsQueryParams = z.infer + +export const FomcDocumentsDataSchema = z.object({ + date: z.string().describe('Meeting or document date.'), + title: z.string().nullable().default(null).describe('Document title.'), + type: z.string().nullable().default(null).describe('Document type (statement, minutes, etc).'), + url: z.string().nullable().default(null).describe('URL to the document.'), +}).passthrough() + +export type FomcDocumentsData = z.infer diff --git a/packages/opentypebb/src/standard-models/forward-ebitda-estimates.ts b/packages/opentypebb/src/standard-models/forward-ebitda-estimates.ts new file mode 100644 index 00000000..2a1dba81 --- /dev/null +++ b/packages/opentypebb/src/standard-models/forward-ebitda-estimates.ts @@ -0,0 +1,35 @@ +/** + * Forward EBITDA Estimates Standard Model. + * Maps to: standard_models/forward_ebitda_estimates.py + */ + +import { z } from 'zod' + +// --- Query Params --- + +export const ForwardEbitdaEstimatesQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), +}) + +export type ForwardEbitdaEstimatesQueryParams = z.infer + +// --- Data --- + +export const ForwardEbitdaEstimatesDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity.'), + name: z.string().nullable().default(null).describe('Name of the entity.'), + last_updated: z.string().nullable().default(null).describe('Last updated timestamp.'), + period_ending: z.string().nullable().default(null).describe('The end date of the reporting period.'), + fiscal_year: z.number().nullable().default(null).describe('Fiscal year for the estimate.'), + fiscal_period: z.string().nullable().default(null).describe('Fiscal period for the estimate.'), + calendar_year: z.number().nullable().default(null).describe('Calendar year for the estimate.'), + calendar_period: z.string().nullable().default(null).describe('Calendar period for the estimate.'), + low_estimate: z.number().nullable().default(null).describe('Low analyst estimate.'), + high_estimate: z.number().nullable().default(null).describe('High analyst estimate.'), + mean: z.number().nullable().default(null).describe('Mean analyst estimate.'), + median: z.number().nullable().default(null).describe('Median analyst estimate.'), + standard_deviation: z.number().nullable().default(null).describe('Standard deviation of estimates.'), + number_of_analysts: z.number().nullable().default(null).describe('Number of analysts providing estimates.'), +}).passthrough() + +export type ForwardEbitdaEstimatesData = z.infer diff --git a/packages/opentypebb/src/standard-models/forward-eps-estimates.ts b/packages/opentypebb/src/standard-models/forward-eps-estimates.ts new file mode 100644 index 00000000..51b13d81 --- /dev/null +++ b/packages/opentypebb/src/standard-models/forward-eps-estimates.ts @@ -0,0 +1,34 @@ +/** + * Forward EPS Estimates Standard Model. + * Maps to: standard_models/forward_eps_estimates.py + */ + +import { z } from 'zod' + +// --- Query Params --- + +export const ForwardEpsEstimatesQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), +}) + +export type ForwardEpsEstimatesQueryParams = z.infer + +// --- Data --- + +export const ForwardEpsEstimatesDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity.'), + name: z.string().nullable().default(null).describe('Name of the entity.'), + date: z.string().nullable().default(null).describe('The date of the data.'), + fiscal_year: z.number().nullable().default(null).describe('Fiscal year for the estimate.'), + fiscal_period: z.string().nullable().default(null).describe('Fiscal period for the estimate.'), + calendar_year: z.number().nullable().default(null).describe('Calendar year for the estimate.'), + calendar_period: z.string().nullable().default(null).describe('Calendar period for the estimate.'), + low_estimate: z.number().nullable().default(null).describe('Low analyst estimate.'), + high_estimate: z.number().nullable().default(null).describe('High analyst estimate.'), + mean: z.number().nullable().default(null).describe('Mean analyst estimate.'), + median: z.number().nullable().default(null).describe('Median analyst estimate.'), + standard_deviation: z.number().nullable().default(null).describe('Standard deviation of estimates.'), + number_of_analysts: z.number().nullable().default(null).describe('Number of analysts providing estimates.'), +}).passthrough() + +export type ForwardEpsEstimatesData = z.infer diff --git a/packages/opentypebb/src/standard-models/fred-regional.ts b/packages/opentypebb/src/standard-models/fred-regional.ts new file mode 100644 index 00000000..b7b805d8 --- /dev/null +++ b/packages/opentypebb/src/standard-models/fred-regional.ts @@ -0,0 +1,25 @@ +/** + * FRED Regional / GeoFRED Standard Model. + * Maps to: openbb_core/provider/standard_models/fred_regional.py + */ + +import { z } from 'zod' + +export const FredRegionalQueryParamsSchema = z.object({ + symbol: z.string().describe('FRED series group ID for GeoFRED data.'), + region_type: z.string().default('state').describe('Region type: state, msa, county, etc.'), + date: z.string().nullable().default(null).describe('Observation date in YYYY-MM-DD.'), + start_date: z.string().nullable().default(null).describe('Start date for data range.'), + frequency: z.string().nullable().default(null).describe('Data frequency.'), +}).passthrough() + +export type FredRegionalQueryParams = z.infer + +export const FredRegionalDataSchema = z.object({ + date: z.string().describe('Observation date.'), + region: z.string().nullable().default(null).describe('Region name.'), + code: z.string().nullable().default(null).describe('Region code.'), + value: z.number().nullable().default(null).describe('Observation value.'), +}).passthrough() + +export type FredRegionalData = z.infer diff --git a/packages/opentypebb/src/standard-models/fred-release-table.ts b/packages/opentypebb/src/standard-models/fred-release-table.ts new file mode 100644 index 00000000..c78dada4 --- /dev/null +++ b/packages/opentypebb/src/standard-models/fred-release-table.ts @@ -0,0 +1,23 @@ +/** + * FRED Release Table Standard Model. + * Maps to: openbb_core/provider/standard_models/fred_release_table.py + */ + +import { z } from 'zod' + +export const FredReleaseTableQueryParamsSchema = z.object({ + release_id: z.string().describe('FRED release ID.'), + element_id: z.number().nullable().default(null).describe('Element ID within release.'), + date: z.string().nullable().default(null).describe('Observation date in YYYY-MM-DD.'), +}).passthrough() + +export type FredReleaseTableQueryParams = z.infer + +export const FredReleaseTableDataSchema = z.object({ + element_id: z.number().nullable().default(null).describe('Element ID.'), + name: z.string().nullable().default(null).describe('Element name.'), + level: z.string().nullable().default(null).describe('Element level.'), + value: z.string().nullable().default(null).describe('Observation value.'), +}).passthrough() + +export type FredReleaseTableData = z.infer diff --git a/packages/opentypebb/src/standard-models/fred-search.ts b/packages/opentypebb/src/standard-models/fred-search.ts new file mode 100644 index 00000000..ccaa918c --- /dev/null +++ b/packages/opentypebb/src/standard-models/fred-search.ts @@ -0,0 +1,25 @@ +/** + * FRED Search Standard Model. + * Maps to: openbb_core/provider/standard_models/fred_search.py + */ + +import { z } from 'zod' + +export const FredSearchQueryParamsSchema = z.object({ + query: z.string().describe('Search query for FRED series.'), + limit: z.number().default(100).describe('Maximum number of results.'), +}).passthrough() + +export type FredSearchQueryParams = z.infer + +export const FredSearchDataSchema = z.object({ + series_id: z.string().describe('FRED series ID.'), + title: z.string().describe('Series title.'), + frequency: z.string().nullable().default(null).describe('Data frequency.'), + units: z.string().nullable().default(null).describe('Data units.'), + seasonal_adjustment: z.string().nullable().default(null).describe('Seasonal adjustment.'), + last_updated: z.string().nullable().default(null).describe('Last updated timestamp.'), + notes: z.string().nullable().default(null).describe('Series notes.'), +}).passthrough() + +export type FredSearchData = z.infer diff --git a/packages/opentypebb/src/standard-models/fred-series.ts b/packages/opentypebb/src/standard-models/fred-series.ts new file mode 100644 index 00000000..49e9c743 --- /dev/null +++ b/packages/opentypebb/src/standard-models/fred-series.ts @@ -0,0 +1,22 @@ +/** + * FRED Series Standard Model. + * Maps to: openbb_core/provider/standard_models/fred_series.py + */ + +import { z } from 'zod' + +export const FredSeriesQueryParamsSchema = z.object({ + symbol: z.string().describe('FRED series ID(s), comma-separated for multiple.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), + limit: z.number().nullable().default(null).describe('Max observations per series.'), + frequency: z.string().nullable().default(null).describe('Aggregation frequency.'), +}).passthrough() + +export type FredSeriesQueryParams = z.infer + +export const FredSeriesDataSchema = z.object({ + date: z.string().describe('Observation date.'), +}).passthrough() + +export type FredSeriesData = z.infer diff --git a/packages/opentypebb/src/standard-models/futures-curve.ts b/packages/opentypebb/src/standard-models/futures-curve.ts new file mode 100644 index 00000000..4ba9c79a --- /dev/null +++ b/packages/opentypebb/src/standard-models/futures-curve.ts @@ -0,0 +1,21 @@ +/** + * Futures Curve Standard Model. + * Maps to: openbb_core/provider/standard_models/futures_curve.py + */ + +import { z } from 'zod' + +export const FuturesCurveQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), + date: z.string().nullable().default(null).describe('A specific date to get data for.'), +}).passthrough() + +export type FuturesCurveQueryParams = z.infer + +export const FuturesCurveDataSchema = z.object({ + date: z.string().nullable().default(null).describe('The date of the data.'), + expiration: z.string().describe('Futures expiration month.'), + price: z.number().nullable().default(null).describe('The price of the futures contract.'), +}).passthrough() + +export type FuturesCurveData = z.infer diff --git a/packages/opentypebb/src/standard-models/futures-historical.ts b/packages/opentypebb/src/standard-models/futures-historical.ts new file mode 100644 index 00000000..f35eafeb --- /dev/null +++ b/packages/opentypebb/src/standard-models/futures-historical.ts @@ -0,0 +1,26 @@ +/** + * Futures Historical Price Standard Model. + * Maps to: openbb_core/provider/standard_models/futures_historical.py + */ + +import { z } from 'zod' + +export const FuturesHistoricalQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), + expiration: z.string().nullable().default(null).describe('Future expiry date with format YYYY-MM.'), +}).passthrough() + +export type FuturesHistoricalQueryParams = z.infer + +export const FuturesHistoricalDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + open: z.number().describe('Opening price.'), + high: z.number().describe('High price.'), + low: z.number().describe('Low price.'), + close: z.number().describe('Close price.'), + volume: z.number().describe('Trading volume.'), +}).passthrough() + +export type FuturesHistoricalData = z.infer diff --git a/packages/opentypebb/src/standard-models/futures-info.ts b/packages/opentypebb/src/standard-models/futures-info.ts new file mode 100644 index 00000000..004e42a9 --- /dev/null +++ b/packages/opentypebb/src/standard-models/futures-info.ts @@ -0,0 +1,16 @@ +/** + * Futures Info Standard Model. + * Maps to: openbb_core/provider/standard_models/futures_info.py + */ + +import { z } from 'zod' + +export const FuturesInfoQueryParamsSchema = z.object({}).passthrough() + +export type FuturesInfoQueryParams = z.infer + +export const FuturesInfoDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity.'), +}).passthrough() + +export type FuturesInfoData = z.infer diff --git a/packages/opentypebb/src/standard-models/futures-instruments.ts b/packages/opentypebb/src/standard-models/futures-instruments.ts new file mode 100644 index 00000000..31b29fc4 --- /dev/null +++ b/packages/opentypebb/src/standard-models/futures-instruments.ts @@ -0,0 +1,14 @@ +/** + * Futures Instruments Standard Model. + * Maps to: openbb_core/provider/standard_models/futures_instruments.py + */ + +import { z } from 'zod' + +export const FuturesInstrumentsQueryParamsSchema = z.object({}).passthrough() + +export type FuturesInstrumentsQueryParams = z.infer + +export const FuturesInstrumentsDataSchema = z.object({}).passthrough() + +export type FuturesInstrumentsData = z.infer diff --git a/packages/opentypebb/src/standard-models/gdp-forecast.ts b/packages/opentypebb/src/standard-models/gdp-forecast.ts new file mode 100644 index 00000000..7abd72fa --- /dev/null +++ b/packages/opentypebb/src/standard-models/gdp-forecast.ts @@ -0,0 +1,22 @@ +/** + * GDP Forecast Standard Model. + */ + +import { z } from 'zod' + +export const GdpForecastQueryParamsSchema = z.object({ + country: z.string().default('united_states').describe('Country to get GDP forecast for.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), + frequency: z.enum(['annual', 'quarter']).default('annual').describe('Data frequency.'), +}).passthrough() + +export type GdpForecastQueryParams = z.infer + +export const GdpForecastDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + country: z.string().nullable().default(null).describe('Country name.'), + value: z.number().nullable().default(null).describe('GDP forecast value.'), +}).passthrough() + +export type GdpForecastData = z.infer diff --git a/packages/opentypebb/src/standard-models/gdp-nominal.ts b/packages/opentypebb/src/standard-models/gdp-nominal.ts new file mode 100644 index 00000000..9b2d0b6c --- /dev/null +++ b/packages/opentypebb/src/standard-models/gdp-nominal.ts @@ -0,0 +1,22 @@ +/** + * GDP Nominal Standard Model. + */ + +import { z } from 'zod' + +export const GdpNominalQueryParamsSchema = z.object({ + country: z.string().default('united_states').describe('Country to get nominal GDP for.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), + frequency: z.enum(['annual', 'quarter']).default('annual').describe('Data frequency.'), +}).passthrough() + +export type GdpNominalQueryParams = z.infer + +export const GdpNominalDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + country: z.string().nullable().default(null).describe('Country name.'), + value: z.number().nullable().default(null).describe('Nominal GDP value.'), +}).passthrough() + +export type GdpNominalData = z.infer diff --git a/packages/opentypebb/src/standard-models/gdp-real.ts b/packages/opentypebb/src/standard-models/gdp-real.ts new file mode 100644 index 00000000..1e305d4c --- /dev/null +++ b/packages/opentypebb/src/standard-models/gdp-real.ts @@ -0,0 +1,22 @@ +/** + * GDP Real Standard Model. + */ + +import { z } from 'zod' + +export const GdpRealQueryParamsSchema = z.object({ + country: z.string().default('united_states').describe('Country to get real GDP for.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), + frequency: z.enum(['annual', 'quarter']).default('annual').describe('Data frequency.'), +}).passthrough() + +export type GdpRealQueryParams = z.infer + +export const GdpRealDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + country: z.string().nullable().default(null).describe('Country name.'), + value: z.number().nullable().default(null).describe('Real GDP value.'), +}).passthrough() + +export type GdpRealData = z.infer diff --git a/packages/opentypebb/src/standard-models/government-trades.ts b/packages/opentypebb/src/standard-models/government-trades.ts new file mode 100644 index 00000000..3066f867 --- /dev/null +++ b/packages/opentypebb/src/standard-models/government-trades.ts @@ -0,0 +1,21 @@ +/** + * Government Trades Standard Model. + * Maps to: standard_models/government_trades.py + */ + +import { z } from 'zod' + +export const GovernmentTradesQueryParamsSchema = z.object({ + symbol: z.string().nullable().default(null).transform(v => v ? v.toUpperCase() : null).describe('Symbol to get data for.'), + chamber: z.enum(['house', 'senate', 'all']).default('all').describe('Government Chamber.'), + limit: z.coerce.number().nullable().default(null).describe('The number of data entries to return.'), +}) +export type GovernmentTradesQueryParams = z.infer + +export const GovernmentTradesDataSchema = z.object({ + symbol: z.string().nullable().default(null).describe('Symbol representing the entity.'), + date: z.string().describe('The date of the data.'), + transaction_date: z.string().nullable().default(null).describe('Date of Transaction.'), + representative: z.string().nullable().default(null).describe('Name of Representative.'), +}).passthrough() +export type GovernmentTradesData = z.infer diff --git a/packages/opentypebb/src/standard-models/historical-dividends.ts b/packages/opentypebb/src/standard-models/historical-dividends.ts new file mode 100644 index 00000000..cef0d208 --- /dev/null +++ b/packages/opentypebb/src/standard-models/historical-dividends.ts @@ -0,0 +1,20 @@ +/** + * Historical Dividends Standard Model. + * Maps to: standard_models/historical_dividends.py + */ + +import { z } from 'zod' + +export const HistoricalDividendsQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), +}) +export type HistoricalDividendsQueryParams = z.infer + +export const HistoricalDividendsDataSchema = z.object({ + symbol: z.string().nullable().default(null).describe('Symbol representing the entity.'), + ex_dividend_date: z.string().describe('The ex-dividend date - the date on which the stock begins trading without rights to the dividend.'), + amount: z.number().describe('The dividend amount per share.'), +}).passthrough() +export type HistoricalDividendsData = z.infer diff --git a/packages/opentypebb/src/standard-models/historical-employees.ts b/packages/opentypebb/src/standard-models/historical-employees.ts new file mode 100644 index 00000000..38cd75f8 --- /dev/null +++ b/packages/opentypebb/src/standard-models/historical-employees.ts @@ -0,0 +1,20 @@ +/** + * Historical Employees Standard Model. + * Maps to: standard_models/historical_employees.py + */ + +import { z } from 'zod' + +export const HistoricalEmployeesQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), +}) +export type HistoricalEmployeesQueryParams = z.infer + +export const HistoricalEmployeesDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + symbol: z.string().nullable().default(null).describe('Symbol representing the entity.'), + employees: z.number().describe('Number of employees.'), +}).passthrough() +export type HistoricalEmployeesData = z.infer diff --git a/packages/opentypebb/src/standard-models/historical-eps.ts b/packages/opentypebb/src/standard-models/historical-eps.ts new file mode 100644 index 00000000..dfe12f88 --- /dev/null +++ b/packages/opentypebb/src/standard-models/historical-eps.ts @@ -0,0 +1,21 @@ +/** + * Historical EPS Standard Model. + * Maps to: standard_models/historical_eps.py + */ + +import { z } from 'zod' + +export const HistoricalEpsQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), +}) +export type HistoricalEpsQueryParams = z.infer + +const numOrNull = z.number().nullable().default(null) + +export const HistoricalEpsDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity.'), + date: z.string().describe('The date of the data.'), + eps_actual: numOrNull.describe('Actual EPS.'), + eps_estimated: numOrNull.describe('Estimated EPS.'), +}).passthrough() +export type HistoricalEpsData = z.infer diff --git a/packages/opentypebb/src/standard-models/historical-market-cap.ts b/packages/opentypebb/src/standard-models/historical-market-cap.ts new file mode 100644 index 00000000..9c0afc1d --- /dev/null +++ b/packages/opentypebb/src/standard-models/historical-market-cap.ts @@ -0,0 +1,22 @@ +/** + * Historical Market Cap Standard Model. + * Maps to: openbb_core/provider/standard_models/historical_market_cap.py + */ + +import { z } from 'zod' + +export const HistoricalMarketCapQueryParamsSchema = z.object({ + symbol: z.string().describe('Symbol to get data for.'), + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), +}).passthrough() + +export type HistoricalMarketCapQueryParams = z.infer + +export const HistoricalMarketCapDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity.'), + date: z.string().describe('The date of the data.'), + market_cap: z.number().describe('Market capitalization.'), +}).passthrough() + +export type HistoricalMarketCapData = z.infer diff --git a/packages/opentypebb/src/standard-models/historical-splits.ts b/packages/opentypebb/src/standard-models/historical-splits.ts new file mode 100644 index 00000000..67aabac2 --- /dev/null +++ b/packages/opentypebb/src/standard-models/historical-splits.ts @@ -0,0 +1,19 @@ +/** + * Historical Splits Standard Model. + * Maps to: standard_models/historical_splits.py + */ + +import { z } from 'zod' + +export const HistoricalSplitsQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), +}) +export type HistoricalSplitsQueryParams = z.infer + +export const HistoricalSplitsDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + numerator: z.number().nullable().default(null).describe('Numerator of the split.'), + denominator: z.number().nullable().default(null).describe('Denominator of the split.'), + split_ratio: z.string().nullable().default(null).describe('Split ratio.'), +}).passthrough() +export type HistoricalSplitsData = z.infer diff --git a/packages/opentypebb/src/standard-models/house-price-index.ts b/packages/opentypebb/src/standard-models/house-price-index.ts new file mode 100644 index 00000000..edd9489c --- /dev/null +++ b/packages/opentypebb/src/standard-models/house-price-index.ts @@ -0,0 +1,22 @@ +/** + * House Price Index Standard Model. + */ + +import { z } from 'zod' + +export const HousePriceIndexQueryParamsSchema = z.object({ + country: z.string().default('united_states').describe('Country to get house price index for.'), + frequency: z.enum(['annual', 'quarter', 'monthly']).default('quarter').describe('Data frequency.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type HousePriceIndexQueryParams = z.infer + +export const HousePriceIndexDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + country: z.string().nullable().default(null).describe('Country name.'), + value: z.number().nullable().default(null).describe('House price index value.'), +}).passthrough() + +export type HousePriceIndexData = z.infer diff --git a/packages/opentypebb/src/standard-models/income-statement-growth.ts b/packages/opentypebb/src/standard-models/income-statement-growth.ts new file mode 100644 index 00000000..5f8845c3 --- /dev/null +++ b/packages/opentypebb/src/standard-models/income-statement-growth.ts @@ -0,0 +1,25 @@ +/** + * Income Statement Growth Standard Model. + * Maps to: standard_models/income_statement_growth.py + */ + +import { z } from 'zod' + +// --- Query Params --- + +export const IncomeStatementGrowthQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), + limit: z.coerce.number().int().nullable().default(null).describe('The number of data entries to return.'), +}) + +export type IncomeStatementGrowthQueryParams = z.infer + +// --- Data --- + +export const IncomeStatementGrowthDataSchema = z.object({ + period_ending: z.string().describe('The end date of the reporting period.'), + fiscal_period: z.string().nullable().default(null).describe('The fiscal period of the report.'), + fiscal_year: z.coerce.number().int().nullable().default(null).describe('The fiscal year of the fiscal period.'), +}).passthrough() + +export type IncomeStatementGrowthData = z.infer diff --git a/packages/opentypebb/src/standard-models/income-statement.ts b/packages/opentypebb/src/standard-models/income-statement.ts new file mode 100644 index 00000000..d57f4476 --- /dev/null +++ b/packages/opentypebb/src/standard-models/income-statement.ts @@ -0,0 +1,21 @@ +/** + * Income Statement Standard Model. + * Maps to: openbb_core/provider/standard_models/income_statement.py + */ + +import { z } from 'zod' + +export const IncomeStatementQueryParamsSchema = z.object({ + symbol: z.string().transform((v) => v.toUpperCase()), + limit: z.number().int().nonnegative().nullable().default(null).describe('The number of data entries to return.'), +}).passthrough() + +export type IncomeStatementQueryParams = z.infer + +export const IncomeStatementDataSchema = z.object({ + period_ending: z.string().describe('The end date of the reporting period.'), + fiscal_period: z.string().nullable().default(null).describe('The fiscal period of the report.'), + fiscal_year: z.number().int().nullable().default(null).describe('The fiscal year of the fiscal period.'), +}).passthrough() + +export type IncomeStatementData = z.infer diff --git a/packages/opentypebb/src/standard-models/index-constituents.ts b/packages/opentypebb/src/standard-models/index-constituents.ts new file mode 100644 index 00000000..f0192526 --- /dev/null +++ b/packages/opentypebb/src/standard-models/index-constituents.ts @@ -0,0 +1,19 @@ +/** + * Index Constituents Standard Model. + * Maps to: openbb_core/provider/standard_models/index_constituents.py + */ + +import { z } from 'zod' + +export const IndexConstituentsQueryParamsSchema = z.object({ + symbol: z.string().describe('Symbol to get data for.'), +}).passthrough() + +export type IndexConstituentsQueryParams = z.infer + +export const IndexConstituentsDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity.'), + name: z.string().nullable().default(null).describe('Name of the constituent company in the index.'), +}).passthrough() + +export type IndexConstituentsData = z.infer diff --git a/packages/opentypebb/src/standard-models/index-historical.ts b/packages/opentypebb/src/standard-models/index-historical.ts new file mode 100644 index 00000000..5db070a0 --- /dev/null +++ b/packages/opentypebb/src/standard-models/index-historical.ts @@ -0,0 +1,28 @@ +/** + * Index Historical Standard Model. + * Maps to: openbb_core/provider/standard_models/index_historical.py + */ + +import { z } from 'zod' + +const numOrNull = z.number().nullable().default(null) + +export const IndexHistoricalQueryParamsSchema = z.object({ + symbol: z.string().describe('Symbol to get data for.'), + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), +}).passthrough() + +export type IndexHistoricalQueryParams = z.infer + +export const IndexHistoricalDataSchema = z.object({ + symbol: z.string().nullable().default(null).describe('Symbol representing the entity.'), + date: z.string().describe('The date of the data.'), + open: numOrNull.describe('Opening price.'), + high: numOrNull.describe('High price.'), + low: numOrNull.describe('Low price.'), + close: numOrNull.describe('Close price.'), + volume: numOrNull.describe('Trading volume.'), +}).passthrough() + +export type IndexHistoricalData = z.infer diff --git a/packages/opentypebb/src/standard-models/index-search.ts b/packages/opentypebb/src/standard-models/index-search.ts new file mode 100644 index 00000000..56875c41 --- /dev/null +++ b/packages/opentypebb/src/standard-models/index-search.ts @@ -0,0 +1,20 @@ +/** + * Index Search Standard Model. + * Maps to: openbb_core/provider/standard_models/index_search.py + */ + +import { z } from 'zod' + +export const IndexSearchQueryParamsSchema = z.object({ + query: z.string().default('').describe('Search query.'), + is_symbol: z.boolean().default(false).describe('Whether to search by ticker symbol.'), +}).passthrough() + +export type IndexSearchQueryParams = z.infer + +export const IndexSearchDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity.'), + name: z.string().describe('Name of the index.'), +}).passthrough() + +export type IndexSearchData = z.infer diff --git a/packages/opentypebb/src/standard-models/index-sectors.ts b/packages/opentypebb/src/standard-models/index-sectors.ts new file mode 100644 index 00000000..0dfcec57 --- /dev/null +++ b/packages/opentypebb/src/standard-models/index-sectors.ts @@ -0,0 +1,19 @@ +/** + * Index Sectors Standard Model. + * Maps to: openbb_core/provider/standard_models/index_sectors.py + */ + +import { z } from 'zod' + +export const IndexSectorsQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), +}).passthrough() + +export type IndexSectorsQueryParams = z.infer + +export const IndexSectorsDataSchema = z.object({ + sector: z.string().describe('The sector name.'), + weight: z.number().describe('The weight of the sector in the index.'), +}).passthrough() + +export type IndexSectorsData = z.infer diff --git a/packages/opentypebb/src/standard-models/index.ts b/packages/opentypebb/src/standard-models/index.ts new file mode 100644 index 00000000..dab7f62d --- /dev/null +++ b/packages/opentypebb/src/standard-models/index.ts @@ -0,0 +1,825 @@ +/** + * Standard Models — re-export all standard model schemas and types. + * Maps to: openbb_core/provider/standard_models/ + */ + +export { + EquityHistoricalQueryParamsSchema, + type EquityHistoricalQueryParams, + EquityHistoricalDataSchema, + type EquityHistoricalData, +} from './equity-historical.js' + +export { + EquityInfoQueryParamsSchema, + type EquityInfoQueryParams, + EquityInfoDataSchema, + type EquityInfoData, +} from './equity-info.js' + +export { + EquityQuoteQueryParamsSchema, + type EquityQuoteQueryParams, + EquityQuoteDataSchema, + type EquityQuoteData, +} from './equity-quote.js' + +export { + CompanyNewsQueryParamsSchema, + type CompanyNewsQueryParams, + CompanyNewsDataSchema, + type CompanyNewsData, +} from './company-news.js' + +export { + WorldNewsQueryParamsSchema, + type WorldNewsQueryParams, + WorldNewsDataSchema, + type WorldNewsData, +} from './world-news.js' + +export { + CryptoHistoricalQueryParamsSchema, + type CryptoHistoricalQueryParams, + CryptoHistoricalDataSchema, + type CryptoHistoricalData, +} from './crypto-historical.js' + +export { + CurrencyHistoricalQueryParamsSchema, + type CurrencyHistoricalQueryParams, + CurrencyHistoricalDataSchema, + type CurrencyHistoricalData, +} from './currency-historical.js' + +export { + BalanceSheetQueryParamsSchema, + type BalanceSheetQueryParams, + BalanceSheetDataSchema, + type BalanceSheetData, +} from './balance-sheet.js' + +export { + IncomeStatementQueryParamsSchema, + type IncomeStatementQueryParams, + IncomeStatementDataSchema, + type IncomeStatementData, +} from './income-statement.js' + +export { + CashFlowStatementQueryParamsSchema, + type CashFlowStatementQueryParams, + CashFlowStatementDataSchema, + type CashFlowStatementData, +} from './cash-flow.js' + +export { + FinancialRatiosQueryParamsSchema, + type FinancialRatiosQueryParams, + FinancialRatiosDataSchema, + type FinancialRatiosData, +} from './financial-ratios.js' + +export { + KeyMetricsQueryParamsSchema, + type KeyMetricsQueryParams, + KeyMetricsDataSchema, + type KeyMetricsData, +} from './key-metrics.js' + +export { + InsiderTradingQueryParamsSchema, + type InsiderTradingQueryParams, + InsiderTradingDataSchema, + type InsiderTradingData, +} from './insider-trading.js' + +export { + CalendarEarningsQueryParamsSchema, + type CalendarEarningsQueryParams, + CalendarEarningsDataSchema, + type CalendarEarningsData, +} from './calendar-earnings.js' + +export { + EquityDiscoveryQueryParamsSchema, + type EquityDiscoveryQueryParams, + EquityDiscoveryDataSchema, + type EquityDiscoveryData, +} from './equity-discovery.js' + +export { + PriceTargetConsensusQueryParamsSchema, + type PriceTargetConsensusQueryParams, + PriceTargetConsensusDataSchema, + type PriceTargetConsensusData, +} from './price-target-consensus.js' + +export { + CryptoSearchQueryParamsSchema, + type CryptoSearchQueryParams, + CryptoSearchDataSchema, + type CryptoSearchData, +} from './crypto-search.js' + +export { + CurrencyPairsQueryParamsSchema, + type CurrencyPairsQueryParams, + CurrencyPairsDataSchema, + type CurrencyPairsData, +} from './currency-pairs.js' + +export { + EquityPerformanceQueryParamsSchema, + type EquityPerformanceQueryParams, + EquityPerformanceDataSchema, + type EquityPerformanceData, +} from './equity-performance.js' + +export { + BalanceSheetGrowthQueryParamsSchema, + type BalanceSheetGrowthQueryParams, + BalanceSheetGrowthDataSchema, + type BalanceSheetGrowthData, +} from './balance-sheet-growth.js' + +export { + IncomeStatementGrowthQueryParamsSchema, + type IncomeStatementGrowthQueryParams, + IncomeStatementGrowthDataSchema, + type IncomeStatementGrowthData, +} from './income-statement-growth.js' + +export { + CashFlowStatementGrowthQueryParamsSchema, + type CashFlowStatementGrowthQueryParams, + CashFlowStatementGrowthDataSchema, + type CashFlowStatementGrowthData, +} from './cash-flow-growth.js' + +export { + CalendarDividendQueryParamsSchema, + type CalendarDividendQueryParams, + CalendarDividendDataSchema, + type CalendarDividendData, +} from './calendar-dividend.js' + +export { + CalendarSplitsQueryParamsSchema, + type CalendarSplitsQueryParams, + CalendarSplitsDataSchema, + type CalendarSplitsData, +} from './calendar-splits.js' + +export { + CalendarIpoQueryParamsSchema, + type CalendarIpoQueryParams, + CalendarIpoDataSchema, + type CalendarIpoData, +} from './calendar-ipo.js' + +export { + EconomicCalendarQueryParamsSchema, + type EconomicCalendarQueryParams, + EconomicCalendarDataSchema, + type EconomicCalendarData, +} from './economic-calendar.js' + +export { + AnalystEstimatesQueryParamsSchema, + type AnalystEstimatesQueryParams, + AnalystEstimatesDataSchema, + type AnalystEstimatesData, +} from './analyst-estimates.js' + +export { + ForwardEpsEstimatesQueryParamsSchema, + type ForwardEpsEstimatesQueryParams, + ForwardEpsEstimatesDataSchema, + type ForwardEpsEstimatesData, +} from './forward-eps-estimates.js' + +export { + ForwardEbitdaEstimatesQueryParamsSchema, + type ForwardEbitdaEstimatesQueryParams, + ForwardEbitdaEstimatesDataSchema, + type ForwardEbitdaEstimatesData, +} from './forward-ebitda-estimates.js' + +export { + PriceTargetQueryParamsSchema, + type PriceTargetQueryParams, + PriceTargetDataSchema, + type PriceTargetData, +} from './price-target.js' + +export { + EtfInfoQueryParamsSchema, + type EtfInfoQueryParams, + EtfInfoDataSchema, + type EtfInfoData, +} from './etf-info.js' + +export { + EtfHoldingsQueryParamsSchema, + type EtfHoldingsQueryParams, + EtfHoldingsDataSchema, + type EtfHoldingsData, +} from './etf-holdings.js' + +export { + EtfSectorsQueryParamsSchema, + type EtfSectorsQueryParams, + EtfSectorsDataSchema, + type EtfSectorsData, +} from './etf-sectors.js' + +export { + EtfCountriesQueryParamsSchema, + type EtfCountriesQueryParams, + EtfCountriesDataSchema, + type EtfCountriesData, +} from './etf-countries.js' + +export { + EtfEquityExposureQueryParamsSchema, + type EtfEquityExposureQueryParams, + EtfEquityExposureDataSchema, + type EtfEquityExposureData, +} from './etf-equity-exposure.js' + +export { + EtfSearchQueryParamsSchema, + type EtfSearchQueryParams, + EtfSearchDataSchema, + type EtfSearchData, +} from './etf-search.js' + +export { + KeyExecutivesQueryParamsSchema, + type KeyExecutivesQueryParams, + KeyExecutivesDataSchema, + type KeyExecutivesData, +} from './key-executives.js' + +export { + HistoricalDividendsQueryParamsSchema, + type HistoricalDividendsQueryParams, + HistoricalDividendsDataSchema, + type HistoricalDividendsData, +} from './historical-dividends.js' + +export { + ShareStatisticsQueryParamsSchema, + type ShareStatisticsQueryParams, + ShareStatisticsDataSchema, + type ShareStatisticsData, +} from './share-statistics.js' + +export { + ExecutiveCompensationQueryParamsSchema, + type ExecutiveCompensationQueryParams, + ExecutiveCompensationDataSchema, + type ExecutiveCompensationData, +} from './executive-compensation.js' + +export { + GovernmentTradesQueryParamsSchema, + type GovernmentTradesQueryParams, + GovernmentTradesDataSchema, + type GovernmentTradesData, +} from './government-trades.js' + +export { + InstitutionalOwnershipQueryParamsSchema, + type InstitutionalOwnershipQueryParams, + InstitutionalOwnershipDataSchema, + type InstitutionalOwnershipData, +} from './institutional-ownership.js' + +export { + HistoricalSplitsQueryParamsSchema, + type HistoricalSplitsQueryParams, + HistoricalSplitsDataSchema, + type HistoricalSplitsData, +} from './historical-splits.js' + +export { + HistoricalEpsQueryParamsSchema, + type HistoricalEpsQueryParams, + HistoricalEpsDataSchema, + type HistoricalEpsData, +} from './historical-eps.js' + +export { + HistoricalEmployeesQueryParamsSchema, + type HistoricalEmployeesQueryParams, + HistoricalEmployeesDataSchema, + type HistoricalEmployeesData, +} from './historical-employees.js' + +export { + EquityPeersQueryParamsSchema, + type EquityPeersQueryParams, + EquityPeersDataSchema, + type EquityPeersData, +} from './equity-peers.js' + +export { + EquityScreenerQueryParamsSchema, + type EquityScreenerQueryParams, + EquityScreenerDataSchema, + type EquityScreenerData, +} from './equity-screener.js' + +export { + CompanyFilingsQueryParamsSchema, + type CompanyFilingsQueryParams, + CompanyFilingsDataSchema, + type CompanyFilingsData, +} from './company-filings.js' + +export { + RecentPerformanceQueryParamsSchema, + type RecentPerformanceQueryParams, + RecentPerformanceDataSchema, + type RecentPerformanceData, +} from './recent-performance.js' + +export { + MarketSnapshotsQueryParamsSchema, + type MarketSnapshotsQueryParams, + MarketSnapshotsDataSchema, + type MarketSnapshotsData, +} from './market-snapshots.js' + +export { + CurrencySnapshotsQueryParamsSchema, + type CurrencySnapshotsQueryParams, + CurrencySnapshotsDataSchema, + type CurrencySnapshotsData, +} from './currency-snapshots.js' + +export { + AvailableIndicesQueryParamsSchema, + type AvailableIndicesQueryParams, + AvailableIndicesDataSchema, + type AvailableIndicesData, +} from './available-indices.js' + +export { + IndexConstituentsQueryParamsSchema, + type IndexConstituentsQueryParams, + IndexConstituentsDataSchema, + type IndexConstituentsData, +} from './index-constituents.js' + +export { + IndexHistoricalQueryParamsSchema, + type IndexHistoricalQueryParams, + IndexHistoricalDataSchema, + type IndexHistoricalData, +} from './index-historical.js' + +export { + RiskPremiumQueryParamsSchema, + type RiskPremiumQueryParams, + RiskPremiumDataSchema, + type RiskPremiumData, +} from './risk-premium.js' + +export { + TreasuryRatesQueryParamsSchema, + type TreasuryRatesQueryParams, + TreasuryRatesDataSchema, + type TreasuryRatesData, +} from './treasury-rates.js' + +export { + RevenueBusinessLineQueryParamsSchema, + type RevenueBusinessLineQueryParams, + RevenueBusinessLineDataSchema, + type RevenueBusinessLineData, +} from './revenue-business-line.js' + +export { + RevenueGeographicQueryParamsSchema, + type RevenueGeographicQueryParams, + RevenueGeographicDataSchema, + type RevenueGeographicData, +} from './revenue-geographic.js' + +export { + EarningsCallTranscriptQueryParamsSchema, + type EarningsCallTranscriptQueryParams, + EarningsCallTranscriptDataSchema, + type EarningsCallTranscriptData, +} from './earnings-call-transcript.js' + +export { + DiscoveryFilingsQueryParamsSchema, + type DiscoveryFilingsQueryParams, + DiscoveryFilingsDataSchema, + type DiscoveryFilingsData, +} from './discovery-filings.js' + +export { + HistoricalMarketCapQueryParamsSchema, + type HistoricalMarketCapQueryParams, + HistoricalMarketCapDataSchema, + type HistoricalMarketCapData, +} from './historical-market-cap.js' + +export { + EsgScoreQueryParamsSchema, + type EsgScoreQueryParams, + EsgScoreDataSchema, + type EsgScoreData, +} from './esg-score.js' + +export { + FuturesHistoricalQueryParamsSchema, + type FuturesHistoricalQueryParams, + FuturesHistoricalDataSchema, + type FuturesHistoricalData, +} from './futures-historical.js' + +export { + FuturesCurveQueryParamsSchema, + type FuturesCurveQueryParams, + FuturesCurveDataSchema, + type FuturesCurveData, +} from './futures-curve.js' + +export { + OptionsChainsQueryParamsSchema, + type OptionsChainsQueryParams, + OptionsChainsDataSchema, + type OptionsChainsData, +} from './options-chains.js' + +export { + OptionsSnapshotsQueryParamsSchema, + type OptionsSnapshotsQueryParams, + OptionsSnapshotsDataSchema, + type OptionsSnapshotsData, +} from './options-snapshots.js' + +export { + OptionsUnusualQueryParamsSchema, + type OptionsUnusualQueryParams, + OptionsUnusualDataSchema, + type OptionsUnusualData, +} from './options-unusual.js' + +export { + IndexSearchQueryParamsSchema, + type IndexSearchQueryParams, + IndexSearchDataSchema, + type IndexSearchData, +} from './index-search.js' + +export { + IndexSectorsQueryParamsSchema, + type IndexSectorsQueryParams, + IndexSectorsDataSchema, + type IndexSectorsData, +} from './index-sectors.js' + +export { + SP500MultiplesQueryParamsSchema, + type SP500MultiplesQueryParams, + SP500MultiplesDataSchema, + type SP500MultiplesData, +} from './sp500-multiples.js' + +export { + AvailableIndicatorsQueryParamsSchema, + type AvailableIndicatorsQueryParams, + AvailableIndicatorsDataSchema, + type AvailableIndicatorsData, +} from './available-indicators.js' + +export { + ConsumerPriceIndexQueryParamsSchema, + type ConsumerPriceIndexQueryParams, + ConsumerPriceIndexDataSchema, + type ConsumerPriceIndexData, +} from './consumer-price-index.js' + +export { + CompositeLeadingIndicatorQueryParamsSchema, + type CompositeLeadingIndicatorQueryParams, + CompositeLeadingIndicatorDataSchema, + type CompositeLeadingIndicatorData, +} from './composite-leading-indicator.js' + +export { + CountryInterestRatesQueryParamsSchema, + type CountryInterestRatesQueryParams, + CountryInterestRatesDataSchema, + type CountryInterestRatesData, +} from './country-interest-rates.js' + +export { + BalanceOfPaymentsQueryParamsSchema, + type BalanceOfPaymentsQueryParams, + BalanceOfPaymentsDataSchema, + type BalanceOfPaymentsData, +} from './balance-of-payments.js' + +export { + CentralBankHoldingsQueryParamsSchema, + type CentralBankHoldingsQueryParams, + CentralBankHoldingsDataSchema, + type CentralBankHoldingsData, +} from './central-bank-holdings.js' + +export { + CountryProfileQueryParamsSchema, + type CountryProfileQueryParams, + CountryProfileDataSchema, + type CountryProfileData, +} from './country-profile.js' + +export { + DirectionOfTradeQueryParamsSchema, + type DirectionOfTradeQueryParams, + DirectionOfTradeDataSchema, + type DirectionOfTradeData, +} from './direction-of-trade.js' + +export { + ExportDestinationsQueryParamsSchema, + type ExportDestinationsQueryParams, + ExportDestinationsDataSchema, + type ExportDestinationsData, +} from './export-destinations.js' + +export { + EconomicIndicatorsQueryParamsSchema, + type EconomicIndicatorsQueryParams, + EconomicIndicatorsDataSchema, + type EconomicIndicatorsData, +} from './economic-indicators.js' + +export { + FuturesInfoQueryParamsSchema, + type FuturesInfoQueryParams, + FuturesInfoDataSchema, + type FuturesInfoData, +} from './futures-info.js' + +export { + FuturesInstrumentsQueryParamsSchema, + type FuturesInstrumentsQueryParams, + FuturesInstrumentsDataSchema, + type FuturesInstrumentsData, +} from './futures-instruments.js' + +// --- FRED --- + +export { + FredSearchQueryParamsSchema, + type FredSearchQueryParams, + FredSearchDataSchema, + type FredSearchData, +} from './fred-search.js' + +export { + FredSeriesQueryParamsSchema, + type FredSeriesQueryParams, + FredSeriesDataSchema, + type FredSeriesData, +} from './fred-series.js' + +export { + FredReleaseTableQueryParamsSchema, + type FredReleaseTableQueryParams, + FredReleaseTableDataSchema, + type FredReleaseTableData, +} from './fred-release-table.js' + +export { + FredRegionalQueryParamsSchema, + type FredRegionalQueryParams, + FredRegionalDataSchema, + type FredRegionalData, +} from './fred-regional.js' + +// --- Macro indicators --- + +export { + UnemploymentQueryParamsSchema, + type UnemploymentQueryParams, + UnemploymentDataSchema, + type UnemploymentData, +} from './unemployment.js' + +export { + MoneyMeasuresQueryParamsSchema, + type MoneyMeasuresQueryParams, + MoneyMeasuresDataSchema, + type MoneyMeasuresData, +} from './money-measures.js' + +export { + PersonalConsumptionExpendituresQueryParamsSchema, + type PersonalConsumptionExpendituresQueryParams, + PersonalConsumptionExpendituresDataSchema, + type PersonalConsumptionExpendituresData, +} from './pce.js' + +export { + TotalFactorProductivityQueryParamsSchema, + type TotalFactorProductivityQueryParams, + TotalFactorProductivityDataSchema, + type TotalFactorProductivityData, +} from './total-factor-productivity.js' + +export { + FomcDocumentsQueryParamsSchema, + type FomcDocumentsQueryParams, + FomcDocumentsDataSchema, + type FomcDocumentsData, +} from './fomc-documents.js' + +export { + PrimaryDealerPositioningQueryParamsSchema, + type PrimaryDealerPositioningQueryParams, + PrimaryDealerPositioningDataSchema, + type PrimaryDealerPositioningData, +} from './primary-dealer-positioning.js' + +export { + PrimaryDealerFailsQueryParamsSchema, + type PrimaryDealerFailsQueryParams, + PrimaryDealerFailsDataSchema, + type PrimaryDealerFailsData, +} from './primary-dealer-fails.js' + +// --- Survey --- + +export { + NonfarmPayrollsQueryParamsSchema, + type NonfarmPayrollsQueryParams, + NonfarmPayrollsDataSchema, + type NonfarmPayrollsData, +} from './nonfarm-payrolls.js' + +export { + InflationExpectationsQueryParamsSchema, + type InflationExpectationsQueryParams, + InflationExpectationsDataSchema, + type InflationExpectationsData, +} from './inflation-expectations.js' + +export { + SloosQueryParamsSchema, + type SloosQueryParams, + SloosDataSchema, + type SloosData, +} from './sloos.js' + +export { + UniversityOfMichiganQueryParamsSchema, + type UniversityOfMichiganQueryParams, + UniversityOfMichiganDataSchema, + type UniversityOfMichiganData, +} from './university-of-michigan.js' + +export { + EconomicConditionsChicagoQueryParamsSchema, + type EconomicConditionsChicagoQueryParams, + EconomicConditionsChicagoDataSchema, + type EconomicConditionsChicagoData, +} from './economic-conditions-chicago.js' + +export { + ManufacturingOutlookNYQueryParamsSchema, + type ManufacturingOutlookNYQueryParams, + ManufacturingOutlookNYDataSchema, + type ManufacturingOutlookNYData, +} from './manufacturing-outlook-ny.js' + +export { + ManufacturingOutlookTexasQueryParamsSchema, + type ManufacturingOutlookTexasQueryParams, + ManufacturingOutlookTexasDataSchema, + type ManufacturingOutlookTexasData, +} from './manufacturing-outlook-texas.js' + +// --- GDP --- + +export { + GdpForecastQueryParamsSchema, + type GdpForecastQueryParams, + GdpForecastDataSchema, + type GdpForecastData, +} from './gdp-forecast.js' + +export { + GdpNominalQueryParamsSchema, + type GdpNominalQueryParams, + GdpNominalDataSchema, + type GdpNominalData, +} from './gdp-nominal.js' + +export { + GdpRealQueryParamsSchema, + type GdpRealQueryParams, + GdpRealDataSchema, + type GdpRealData, +} from './gdp-real.js' + +// --- OECD --- + +export { + SharePriceIndexQueryParamsSchema, + type SharePriceIndexQueryParams, + SharePriceIndexDataSchema, + type SharePriceIndexData, +} from './share-price-index.js' + +export { + HousePriceIndexQueryParamsSchema, + type HousePriceIndexQueryParams, + HousePriceIndexDataSchema, + type HousePriceIndexData, +} from './house-price-index.js' + +export { + RetailPricesQueryParamsSchema, + type RetailPricesQueryParams, + RetailPricesDataSchema, + type RetailPricesData, +} from './retail-prices.js' + +// --- BLS --- + +export { + BlsSeriesQueryParamsSchema, + type BlsSeriesQueryParams, + BlsSeriesDataSchema, + type BlsSeriesData, +} from './bls-series.js' + +export { + BlsSearchQueryParamsSchema, + type BlsSearchQueryParams, + BlsSearchDataSchema, + type BlsSearchData, +} from './bls-search.js' + +// --- Commodity --- + +export { + CommoditySpotPriceQueryParamsSchema, + type CommoditySpotPriceQueryParams, + CommoditySpotPriceDataSchema, + type CommoditySpotPriceData, +} from './commodity-spot-price.js' + +export { + PetroleumStatusReportQueryParamsSchema, + type PetroleumStatusReportQueryParams, + PetroleumStatusReportDataSchema, + type PetroleumStatusReportData, +} from './petroleum-status-report.js' + +export { + ShortTermEnergyOutlookQueryParamsSchema, + type ShortTermEnergyOutlookQueryParams, + ShortTermEnergyOutlookDataSchema, + type ShortTermEnergyOutlookData, +} from './short-term-energy-outlook.js' + +// --- Shipping (Stubs) --- + +export { + PortInfoQueryParamsSchema, + type PortInfoQueryParams, + PortInfoDataSchema, + type PortInfoData, +} from './port-info.js' + +export { + PortVolumeQueryParamsSchema, + type PortVolumeQueryParams, + PortVolumeDataSchema, + type PortVolumeData, +} from './port-volume.js' + +export { + ChokepointInfoQueryParamsSchema, + type ChokepointInfoQueryParams, + ChokepointInfoDataSchema, + type ChokepointInfoData, +} from './chokepoint-info.js' + +export { + ChokepointVolumeQueryParamsSchema, + type ChokepointVolumeQueryParams, + ChokepointVolumeDataSchema, + type ChokepointVolumeData, +} from './chokepoint-volume.js' diff --git a/packages/opentypebb/src/standard-models/inflation-expectations.ts b/packages/opentypebb/src/standard-models/inflation-expectations.ts new file mode 100644 index 00000000..b641ec26 --- /dev/null +++ b/packages/opentypebb/src/standard-models/inflation-expectations.ts @@ -0,0 +1,23 @@ +/** + * Inflation Expectations Standard Model. + * Maps to: openbb_core/provider/standard_models/inflation_expectations.py + */ + +import { z } from 'zod' + +export const InflationExpectationsQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type InflationExpectationsQueryParams = z.infer + +export const InflationExpectationsDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + michigan_1y: z.number().nullable().default(null).describe('University of Michigan 1-year inflation expectation.'), + michigan_5y: z.number().nullable().default(null).describe('University of Michigan 5-year inflation expectation.'), + breakeven_5y: z.number().nullable().default(null).describe('5-Year breakeven inflation rate (T5YIE).'), + breakeven_10y: z.number().nullable().default(null).describe('10-Year breakeven inflation rate (T10YIE).'), +}).passthrough() + +export type InflationExpectationsData = z.infer diff --git a/packages/opentypebb/src/standard-models/insider-trading.ts b/packages/opentypebb/src/standard-models/insider-trading.ts new file mode 100644 index 00000000..c45c2811 --- /dev/null +++ b/packages/opentypebb/src/standard-models/insider-trading.ts @@ -0,0 +1,33 @@ +/** + * Insider Trading Standard Model. + * Maps to: openbb_core/provider/standard_models/insider_trading.py + */ + +import { z } from 'zod' + +export const InsiderTradingQueryParamsSchema = z.object({ + symbol: z.string().transform((v) => v.toUpperCase()), + limit: z.number().int().nullable().default(null).describe('The number of data entries to return.'), +}).passthrough() + +export type InsiderTradingQueryParams = z.infer + +export const InsiderTradingDataSchema = z.object({ + symbol: z.string().nullable().default(null).describe('Symbol representing the entity requested in the data.'), + company_cik: z.string().nullable().default(null).describe('CIK number of the company.'), + filing_date: z.string().nullable().default(null).describe('Filing date of the trade.'), + transaction_date: z.string().nullable().default(null).describe('Date of the transaction.'), + owner_cik: z.union([z.number(), z.string()]).nullable().default(null).describe("Reporting individual's CIK."), + owner_name: z.string().nullable().default(null).describe('Name of the reporting individual.'), + owner_title: z.string().nullable().default(null).describe('The title held by the reporting individual.'), + ownership_type: z.string().nullable().default(null).describe('Type of ownership, e.g., direct or indirect.'), + transaction_type: z.string().nullable().default(null).describe('Type of transaction being reported.'), + acquisition_or_disposition: z.string().nullable().default(null).describe('Acquisition or disposition of the shares.'), + security_type: z.string().nullable().default(null).describe('The type of security transacted.'), + securities_owned: z.number().nullable().default(null).describe('Number of securities owned by the reporting individual.'), + securities_transacted: z.number().nullable().default(null).describe('Number of securities transacted.'), + transaction_price: z.number().nullable().default(null).describe('The price of the transaction.'), + filing_url: z.string().nullable().default(null).describe('Link to the filing.'), +}).passthrough() + +export type InsiderTradingData = z.infer diff --git a/packages/opentypebb/src/standard-models/institutional-ownership.ts b/packages/opentypebb/src/standard-models/institutional-ownership.ts new file mode 100644 index 00000000..7576177b --- /dev/null +++ b/packages/opentypebb/src/standard-models/institutional-ownership.ts @@ -0,0 +1,18 @@ +/** + * Institutional Ownership Standard Model. + * Maps to: standard_models/institutional_ownership.py + */ + +import { z } from 'zod' + +export const InstitutionalOwnershipQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), +}) +export type InstitutionalOwnershipQueryParams = z.infer + +export const InstitutionalOwnershipDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity.'), + cik: z.string().nullable().default(null).describe('CIK number.'), + date: z.string().describe('The date of the data.'), +}).passthrough() +export type InstitutionalOwnershipData = z.infer diff --git a/packages/opentypebb/src/standard-models/key-executives.ts b/packages/opentypebb/src/standard-models/key-executives.ts new file mode 100644 index 00000000..bdba8b2d --- /dev/null +++ b/packages/opentypebb/src/standard-models/key-executives.ts @@ -0,0 +1,21 @@ +/** + * Key Executives Standard Model. + * Maps to: standard_models/key_executives.py + */ + +import { z } from 'zod' + +export const KeyExecutivesQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), +}) +export type KeyExecutivesQueryParams = z.infer + +export const KeyExecutivesDataSchema = z.object({ + title: z.string().describe('Designation of the key executive.'), + name: z.string().describe('Name of the key executive.'), + pay: z.number().nullable().default(null).describe('Pay of the key executive.'), + currency_pay: z.string().nullable().default(null).describe('Currency of the pay.'), + gender: z.string().nullable().default(null).describe('Gender of the key executive.'), + year_born: z.number().nullable().default(null).describe('Birth year of the key executive.'), +}).passthrough() +export type KeyExecutivesData = z.infer diff --git a/packages/opentypebb/src/standard-models/key-metrics.ts b/packages/opentypebb/src/standard-models/key-metrics.ts new file mode 100644 index 00000000..fdbbb3fa --- /dev/null +++ b/packages/opentypebb/src/standard-models/key-metrics.ts @@ -0,0 +1,23 @@ +/** + * Key Metrics Standard Model. + * Maps to: openbb_core/provider/standard_models/key_metrics.py + */ + +import { z } from 'zod' + +export const KeyMetricsQueryParamsSchema = z.object({ + symbol: z.string().transform((v) => v.toUpperCase()), +}).passthrough() + +export type KeyMetricsQueryParams = z.infer + +export const KeyMetricsDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity requested in the data.'), + period_ending: z.string().nullable().default(null).describe('End date of the reporting period.'), + fiscal_year: z.number().int().nullable().default(null).describe('Fiscal year for the fiscal period.'), + fiscal_period: z.string().nullable().default(null).describe('Fiscal period for the data.'), + currency: z.string().nullable().default(null).describe('Currency in which the data is reported.'), + market_cap: z.number().nullable().default(null).describe('Market capitalization.'), +}).passthrough() + +export type KeyMetricsData = z.infer diff --git a/packages/opentypebb/src/standard-models/manufacturing-outlook-ny.ts b/packages/opentypebb/src/standard-models/manufacturing-outlook-ny.ts new file mode 100644 index 00000000..de16e3b6 --- /dev/null +++ b/packages/opentypebb/src/standard-models/manufacturing-outlook-ny.ts @@ -0,0 +1,22 @@ +/** + * NY Fed Manufacturing Outlook Standard Model. + * Maps to: openbb_core/provider/standard_models/manufacturing_outlook_ny.py + */ + +import { z } from 'zod' + +export const ManufacturingOutlookNYQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type ManufacturingOutlookNYQueryParams = z.infer + +export const ManufacturingOutlookNYDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + general_business_conditions: z.number().nullable().default(null).describe('Empire State general business conditions index.'), + new_orders: z.number().nullable().default(null).describe('New orders diffusion index.'), + employees: z.number().nullable().default(null).describe('Number of employees diffusion index.'), +}).passthrough() + +export type ManufacturingOutlookNYData = z.infer diff --git a/packages/opentypebb/src/standard-models/manufacturing-outlook-texas.ts b/packages/opentypebb/src/standard-models/manufacturing-outlook-texas.ts new file mode 100644 index 00000000..6ce8748b --- /dev/null +++ b/packages/opentypebb/src/standard-models/manufacturing-outlook-texas.ts @@ -0,0 +1,22 @@ +/** + * Dallas Fed Manufacturing Outlook Standard Model. + * Maps to: openbb_core/provider/standard_models/manufacturing_outlook_texas.py + */ + +import { z } from 'zod' + +export const ManufacturingOutlookTexasQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type ManufacturingOutlookTexasQueryParams = z.infer + +export const ManufacturingOutlookTexasDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + general_activity: z.number().nullable().default(null).describe('General business activity index.'), + production: z.number().nullable().default(null).describe('Production index.'), + new_orders: z.number().nullable().default(null).describe('New orders index.'), +}).passthrough() + +export type ManufacturingOutlookTexasData = z.infer diff --git a/packages/opentypebb/src/standard-models/market-snapshots.ts b/packages/opentypebb/src/standard-models/market-snapshots.ts new file mode 100644 index 00000000..c8c0fa4f --- /dev/null +++ b/packages/opentypebb/src/standard-models/market-snapshots.ts @@ -0,0 +1,28 @@ +/** + * Market Snapshots Standard Model. + * Maps to: openbb_core/provider/standard_models/market_snapshots.py + */ + +import { z } from 'zod' + +export const MarketSnapshotsQueryParamsSchema = z.object({}).passthrough() + +export type MarketSnapshotsQueryParams = z.infer + +const numOrNull = z.number().nullable().default(null) + +export const MarketSnapshotsDataSchema = z.object({ + exchange: z.string().nullable().default(null).describe('Exchange the security is listed on.'), + symbol: z.string().describe('Symbol representing the entity.'), + name: z.string().nullable().default(null).describe('Name of the company, fund, or security.'), + open: numOrNull.describe('Opening price.'), + high: numOrNull.describe('High price.'), + low: numOrNull.describe('Low price.'), + close: numOrNull.describe('Close price.'), + volume: numOrNull.describe('Trading volume.'), + prev_close: numOrNull.describe('Previous close price.'), + change: numOrNull.describe('The change in price from the previous close.'), + change_percent: numOrNull.describe('The change in price from the previous close, as a normalized percent.'), +}).passthrough() + +export type MarketSnapshotsData = z.infer diff --git a/packages/opentypebb/src/standard-models/money-measures.ts b/packages/opentypebb/src/standard-models/money-measures.ts new file mode 100644 index 00000000..69069ce7 --- /dev/null +++ b/packages/opentypebb/src/standard-models/money-measures.ts @@ -0,0 +1,22 @@ +/** + * Money Measures Standard Model. + * Maps to: openbb_core/provider/standard_models/money_measures.py + */ + +import { z } from 'zod' + +export const MoneyMeasuresQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), + adjusted: z.boolean().default(true).describe('If true, returns seasonally adjusted data.'), +}).passthrough() + +export type MoneyMeasuresQueryParams = z.infer + +export const MoneyMeasuresDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + m1: z.number().nullable().default(null).describe('M1 money supply (billions USD).'), + m2: z.number().nullable().default(null).describe('M2 money supply (billions USD).'), +}).passthrough() + +export type MoneyMeasuresData = z.infer diff --git a/packages/opentypebb/src/standard-models/nonfarm-payrolls.ts b/packages/opentypebb/src/standard-models/nonfarm-payrolls.ts new file mode 100644 index 00000000..a9017642 --- /dev/null +++ b/packages/opentypebb/src/standard-models/nonfarm-payrolls.ts @@ -0,0 +1,22 @@ +/** + * Nonfarm Payrolls Standard Model. + * Maps to: openbb_core/provider/standard_models/nonfarm_payrolls.py + */ + +import { z } from 'zod' + +export const NonfarmPayrollsQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type NonfarmPayrollsQueryParams = z.infer + +export const NonfarmPayrollsDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + total_nonfarm: z.number().nullable().default(null).describe('Total nonfarm payrolls (thousands).'), + private_sector: z.number().nullable().default(null).describe('Private sector payrolls (thousands).'), + government: z.number().nullable().default(null).describe('Government payrolls (thousands).'), +}).passthrough() + +export type NonfarmPayrollsData = z.infer diff --git a/packages/opentypebb/src/standard-models/options-chains.ts b/packages/opentypebb/src/standard-models/options-chains.ts new file mode 100644 index 00000000..acfab1ee --- /dev/null +++ b/packages/opentypebb/src/standard-models/options-chains.ts @@ -0,0 +1,54 @@ +/** + * Options Chains Standard Model. + * Maps to: openbb_core/provider/standard_models/options_chains.py + * + * Note: Python uses list-typed fields + model_serializer to zip into records. + * In TypeScript we define the per-record schema directly. + */ + +import { z } from 'zod' + +export const OptionsChainsQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), +}).passthrough() + +export type OptionsChainsQueryParams = z.infer + +export const OptionsChainsDataSchema = z.object({ + underlying_symbol: z.string().nullable().default(null).describe('Underlying symbol for the option.'), + underlying_price: z.number().nullable().default(null).describe('Price of the underlying stock.'), + contract_symbol: z.string().describe('Contract symbol for the option.'), + eod_date: z.string().nullable().default(null).describe('Date for which the options chains are returned.'), + expiration: z.string().describe('Expiration date of the contract.'), + dte: z.number().nullable().default(null).describe('Days to expiration of the contract.'), + strike: z.number().describe('Strike price of the contract.'), + option_type: z.string().describe('Call or Put.'), + contract_size: z.number().nullable().default(null).describe('Number of underlying units per contract.'), + open_interest: z.number().nullable().default(null).describe('Open interest on the contract.'), + volume: z.number().nullable().default(null).describe('Trading volume.'), + theoretical_price: z.number().nullable().default(null).describe('Theoretical value of the option.'), + last_trade_price: z.number().nullable().default(null).describe('Last trade price of the option.'), + last_trade_size: z.number().nullable().default(null).describe('Last trade size of the option.'), + last_trade_time: z.string().nullable().default(null).describe('The timestamp of the last trade.'), + tick: z.string().nullable().default(null).describe('Whether the last tick was up or down in price.'), + bid: z.number().nullable().default(null).describe('Current bid price for the option.'), + bid_size: z.number().nullable().default(null).describe('Bid size for the option.'), + ask: z.number().nullable().default(null).describe('Current ask price for the option.'), + ask_size: z.number().nullable().default(null).describe('Ask size for the option.'), + mark: z.number().nullable().default(null).describe('The mid-price between the latest bid and ask.'), + open: z.number().nullable().default(null).describe('Opening price.'), + high: z.number().nullable().default(null).describe('High price.'), + low: z.number().nullable().default(null).describe('Low price.'), + close: z.number().nullable().default(null).describe('Close price.'), + prev_close: z.number().nullable().default(null).describe('Previous close price.'), + change: z.number().nullable().default(null).describe('The change in the price of the option.'), + change_percent: z.number().nullable().default(null).describe('Change, in percent, of the option.'), + implied_volatility: z.number().nullable().default(null).describe('Implied volatility of the option.'), + delta: z.number().nullable().default(null).describe('Delta of the option.'), + gamma: z.number().nullable().default(null).describe('Gamma of the option.'), + theta: z.number().nullable().default(null).describe('Theta of the option.'), + vega: z.number().nullable().default(null).describe('Vega of the option.'), + rho: z.number().nullable().default(null).describe('Rho of the option.'), +}).passthrough() + +export type OptionsChainsData = z.infer diff --git a/packages/opentypebb/src/standard-models/options-snapshots.ts b/packages/opentypebb/src/standard-models/options-snapshots.ts new file mode 100644 index 00000000..f49e0df9 --- /dev/null +++ b/packages/opentypebb/src/standard-models/options-snapshots.ts @@ -0,0 +1,32 @@ +/** + * Options Snapshots Standard Model. + * Maps to: openbb_core/provider/standard_models/options_snapshots.py + * + * Note: Python uses list-typed fields. In TypeScript we define per-record schema. + */ + +import { z } from 'zod' + +export const OptionsSnapshotsQueryParamsSchema = z.object({}).passthrough() + +export type OptionsSnapshotsQueryParams = z.infer + +export const OptionsSnapshotsDataSchema = z.object({ + underlying_symbol: z.string().describe('Ticker symbol of the underlying asset.'), + contract_symbol: z.string().describe('Symbol of the options contract.'), + expiration: z.string().describe('Expiration date of the options contract.'), + dte: z.number().nullable().default(null).describe('Number of days to expiration.'), + strike: z.number().describe('Strike price of the options contract.'), + option_type: z.string().describe('The type of option.'), + volume: z.number().nullable().default(null).describe('Trading volume.'), + open_interest: z.number().nullable().default(null).describe('Open interest at the time.'), + last_price: z.number().nullable().default(null).describe('Last trade price.'), + last_size: z.number().nullable().default(null).describe('Lot size of the last trade.'), + last_timestamp: z.string().nullable().default(null).describe('Timestamp of the last price.'), + open: z.number().nullable().default(null).describe('Opening price.'), + high: z.number().nullable().default(null).describe('High price.'), + low: z.number().nullable().default(null).describe('Low price.'), + close: z.number().nullable().default(null).describe('Close price.'), +}).passthrough() + +export type OptionsSnapshotsData = z.infer diff --git a/packages/opentypebb/src/standard-models/options-unusual.ts b/packages/opentypebb/src/standard-models/options-unusual.ts new file mode 100644 index 00000000..cea9d7db --- /dev/null +++ b/packages/opentypebb/src/standard-models/options-unusual.ts @@ -0,0 +1,19 @@ +/** + * Unusual Options Standard Model. + * Maps to: openbb_core/provider/standard_models/options_unusual.py + */ + +import { z } from 'zod' + +export const OptionsUnusualQueryParamsSchema = z.object({ + symbol: z.string().nullable().default(null).transform(v => v ? v.toUpperCase() : null).describe('Symbol to get data for (the underlying symbol).'), +}).passthrough() + +export type OptionsUnusualQueryParams = z.infer + +export const OptionsUnusualDataSchema = z.object({ + underlying_symbol: z.string().nullable().default(null).describe('Symbol of the underlying asset.'), + contract_symbol: z.string().describe('Contract symbol for the option.'), +}).passthrough() + +export type OptionsUnusualData = z.infer diff --git a/packages/opentypebb/src/standard-models/pce.ts b/packages/opentypebb/src/standard-models/pce.ts new file mode 100644 index 00000000..6434319d --- /dev/null +++ b/packages/opentypebb/src/standard-models/pce.ts @@ -0,0 +1,21 @@ +/** + * Personal Consumption Expenditures (PCE) Standard Model. + * Maps to: openbb_core/provider/standard_models/pce.py + */ + +import { z } from 'zod' + +export const PersonalConsumptionExpendituresQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type PersonalConsumptionExpendituresQueryParams = z.infer + +export const PersonalConsumptionExpendituresDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + pce: z.number().nullable().default(null).describe('PCE price index.'), + core_pce: z.number().nullable().default(null).describe('Core PCE price index (excluding food and energy).'), +}).passthrough() + +export type PersonalConsumptionExpendituresData = z.infer diff --git a/packages/opentypebb/src/standard-models/petroleum-status-report.ts b/packages/opentypebb/src/standard-models/petroleum-status-report.ts new file mode 100644 index 00000000..ad5bda0e --- /dev/null +++ b/packages/opentypebb/src/standard-models/petroleum-status-report.ts @@ -0,0 +1,29 @@ +/** + * Petroleum Status Report Standard Model. + * Data from EIA Weekly Petroleum Status Report. + */ + +import { z } from 'zod' + +export const PetroleumStatusReportQueryParamsSchema = z.object({ + category: z.enum([ + 'crude_oil_production', + 'crude_oil_stocks', + 'gasoline_stocks', + 'distillate_stocks', + 'refinery_utilization', + ]).default('crude_oil_stocks').describe('Petroleum data category.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type PetroleumStatusReportQueryParams = z.infer + +export const PetroleumStatusReportDataSchema = z.object({ + date: z.string().describe('Observation date.'), + value: z.number().nullable().default(null).describe('Observation value.'), + category: z.string().nullable().default(null).describe('Data category.'), + unit: z.string().nullable().default(null).describe('Unit of measurement.'), +}).passthrough() + +export type PetroleumStatusReportData = z.infer diff --git a/packages/opentypebb/src/standard-models/port-info.ts b/packages/opentypebb/src/standard-models/port-info.ts new file mode 100644 index 00000000..ab79ad88 --- /dev/null +++ b/packages/opentypebb/src/standard-models/port-info.ts @@ -0,0 +1,21 @@ +/** + * Port Info Standard Model (Stub). + */ + +import { z } from 'zod' + +export const PortInfoQueryParamsSchema = z.object({ + port: z.string().default('').describe('Port code or name.'), +}).passthrough() + +export type PortInfoQueryParams = z.infer + +export const PortInfoDataSchema = z.object({ + port_code: z.string().nullable().default(null).describe('Port code.'), + port_name: z.string().nullable().default(null).describe('Port name.'), + country: z.string().nullable().default(null).describe('Country.'), + latitude: z.number().nullable().default(null).describe('Latitude.'), + longitude: z.number().nullable().default(null).describe('Longitude.'), +}).passthrough() + +export type PortInfoData = z.infer diff --git a/packages/opentypebb/src/standard-models/port-volume.ts b/packages/opentypebb/src/standard-models/port-volume.ts new file mode 100644 index 00000000..faebc48a --- /dev/null +++ b/packages/opentypebb/src/standard-models/port-volume.ts @@ -0,0 +1,21 @@ +/** + * Port Volume Standard Model (Stub). + */ + +import { z } from 'zod' + +export const PortVolumeQueryParamsSchema = z.object({ + port: z.string().default('').describe('Port code or name.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type PortVolumeQueryParams = z.infer + +export const PortVolumeDataSchema = z.object({ + date: z.string().describe('Observation date.'), + port_code: z.string().nullable().default(null).describe('Port code.'), + volume: z.number().nullable().default(null).describe('Shipping volume (TEUs).'), +}).passthrough() + +export type PortVolumeData = z.infer diff --git a/packages/opentypebb/src/standard-models/price-target-consensus.ts b/packages/opentypebb/src/standard-models/price-target-consensus.ts new file mode 100644 index 00000000..fd838b67 --- /dev/null +++ b/packages/opentypebb/src/standard-models/price-target-consensus.ts @@ -0,0 +1,23 @@ +/** + * Price Target Consensus Standard Model. + * Maps to: openbb_core/provider/standard_models/price_target_consensus.py + */ + +import { z } from 'zod' + +export const PriceTargetConsensusQueryParamsSchema = z.object({ + symbol: z.string().nullable().default(null).transform((v) => v?.toUpperCase() ?? null), +}).passthrough() + +export type PriceTargetConsensusQueryParams = z.infer + +export const PriceTargetConsensusDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity requested in the data.'), + name: z.string().nullable().default(null).describe('The company name.'), + target_high: z.number().nullable().default(null).describe('High target of the price target consensus.'), + target_low: z.number().nullable().default(null).describe('Low target of the price target consensus.'), + target_consensus: z.number().nullable().default(null).describe('Consensus target of the price target consensus.'), + target_median: z.number().nullable().default(null).describe('Median target of the price target consensus.'), +}).passthrough() + +export type PriceTargetConsensusData = z.infer diff --git a/packages/opentypebb/src/standard-models/price-target.ts b/packages/opentypebb/src/standard-models/price-target.ts new file mode 100644 index 00000000..1cfee7da --- /dev/null +++ b/packages/opentypebb/src/standard-models/price-target.ts @@ -0,0 +1,38 @@ +/** + * Price Target Standard Model. + * Maps to: standard_models/price_target.py + */ + +import { z } from 'zod' + +// --- Query Params --- + +export const PriceTargetQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), + limit: z.coerce.number().int().nullable().default(200).describe('The number of data entries to return.'), +}) + +export type PriceTargetQueryParams = z.infer + +// --- Data --- + +export const PriceTargetDataSchema = z.object({ + published_date: z.string().nullable().default(null).describe('Published date of the price target.'), + published_time: z.string().nullable().default(null).describe('Published time of the price target.'), + symbol: z.string().describe('Symbol representing the entity.'), + exchange: z.string().nullable().default(null).describe('Exchange where the stock is listed.'), + company_name: z.string().nullable().default(null).describe('Name of the company.'), + analyst_name: z.string().nullable().default(null).describe('Analyst name.'), + analyst_firm: z.string().nullable().default(null).describe('Analyst firm.'), + currency: z.string().nullable().default(null).describe('Currency of the price target.'), + price_target: z.number().nullable().default(null).describe('Price target.'), + adj_price_target: z.number().nullable().default(null).describe('Adjusted price target.'), + price_target_previous: z.number().nullable().default(null).describe('Previous price target.'), + previous_adj_price_target: z.number().nullable().default(null).describe('Previous adjusted price target.'), + price_when_posted: z.number().nullable().default(null).describe('Price when posted.'), + rating_current: z.string().nullable().default(null).describe('Current rating.'), + rating_previous: z.string().nullable().default(null).describe('Previous rating.'), + action: z.string().nullable().default(null).describe('Description of the rating change.'), +}).passthrough() + +export type PriceTargetData = z.infer diff --git a/packages/opentypebb/src/standard-models/primary-dealer-fails.ts b/packages/opentypebb/src/standard-models/primary-dealer-fails.ts new file mode 100644 index 00000000..b28f004b --- /dev/null +++ b/packages/opentypebb/src/standard-models/primary-dealer-fails.ts @@ -0,0 +1,19 @@ +/** + * Primary Dealer Fails Standard Model. + * Maps to: openbb_core/provider/standard_models/primary_dealer_fails.py + */ + +import { z } from 'zod' + +export const PrimaryDealerFailsQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type PrimaryDealerFailsQueryParams = z.infer + +export const PrimaryDealerFailsDataSchema = z.object({ + date: z.string().describe('The date of the data.'), +}).passthrough() + +export type PrimaryDealerFailsData = z.infer diff --git a/packages/opentypebb/src/standard-models/primary-dealer-positioning.ts b/packages/opentypebb/src/standard-models/primary-dealer-positioning.ts new file mode 100644 index 00000000..1c0ed44d --- /dev/null +++ b/packages/opentypebb/src/standard-models/primary-dealer-positioning.ts @@ -0,0 +1,19 @@ +/** + * Primary Dealer Positioning Standard Model. + * Maps to: openbb_core/provider/standard_models/primary_dealer_positioning.py + */ + +import { z } from 'zod' + +export const PrimaryDealerPositioningQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type PrimaryDealerPositioningQueryParams = z.infer + +export const PrimaryDealerPositioningDataSchema = z.object({ + date: z.string().describe('The date of the data.'), +}).passthrough() + +export type PrimaryDealerPositioningData = z.infer diff --git a/packages/opentypebb/src/standard-models/recent-performance.ts b/packages/opentypebb/src/standard-models/recent-performance.ts new file mode 100644 index 00000000..b8367ea1 --- /dev/null +++ b/packages/opentypebb/src/standard-models/recent-performance.ts @@ -0,0 +1,36 @@ +/** + * Recent Performance Standard Model. + * Maps to: openbb_core/provider/standard_models/recent_performance.py + */ + +import { z } from 'zod' + +export const RecentPerformanceQueryParamsSchema = z.object({ + symbol: z.string().describe('Symbol to get data for.'), +}).passthrough() + +export type RecentPerformanceQueryParams = z.infer + +const numOrNull = z.number().nullable().default(null) + +export const RecentPerformanceDataSchema = z.object({ + symbol: z.string().nullable().default(null).describe('The ticker symbol.'), + one_day: numOrNull.describe('One-day return.'), + wtd: numOrNull.describe('Week to date return.'), + one_week: numOrNull.describe('One-week return.'), + mtd: numOrNull.describe('Month to date return.'), + one_month: numOrNull.describe('One-month return.'), + qtd: numOrNull.describe('Quarter to date return.'), + three_month: numOrNull.describe('Three-month return.'), + six_month: numOrNull.describe('Six-month return.'), + ytd: numOrNull.describe('Year to date return.'), + one_year: numOrNull.describe('One-year return.'), + two_year: numOrNull.describe('Two-year return.'), + three_year: numOrNull.describe('Three-year return.'), + four_year: numOrNull.describe('Four-year return.'), + five_year: numOrNull.describe('Five-year return.'), + ten_year: numOrNull.describe('Ten-year return.'), + max: numOrNull.describe('Return from the beginning of the time series.'), +}).passthrough() + +export type RecentPerformanceData = z.infer diff --git a/packages/opentypebb/src/standard-models/retail-prices.ts b/packages/opentypebb/src/standard-models/retail-prices.ts new file mode 100644 index 00000000..acc7ea37 --- /dev/null +++ b/packages/opentypebb/src/standard-models/retail-prices.ts @@ -0,0 +1,22 @@ +/** + * Retail Prices Standard Model. + */ + +import { z } from 'zod' + +export const RetailPricesQueryParamsSchema = z.object({ + country: z.string().default('united_states').describe('Country to get retail price data for.'), + frequency: z.enum(['annual', 'quarter', 'monthly']).default('monthly').describe('Data frequency.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type RetailPricesQueryParams = z.infer + +export const RetailPricesDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + country: z.string().nullable().default(null).describe('Country name.'), + value: z.number().nullable().default(null).describe('Retail price index value.'), +}).passthrough() + +export type RetailPricesData = z.infer diff --git a/packages/opentypebb/src/standard-models/revenue-business-line.ts b/packages/opentypebb/src/standard-models/revenue-business-line.ts new file mode 100644 index 00000000..8e32f849 --- /dev/null +++ b/packages/opentypebb/src/standard-models/revenue-business-line.ts @@ -0,0 +1,23 @@ +/** + * Revenue By Business Line Standard Model. + * Maps to: openbb_core/provider/standard_models/revenue_business_line.py + */ + +import { z } from 'zod' + +export const RevenueBusinessLineQueryParamsSchema = z.object({ + symbol: z.string().describe('Symbol to get data for.'), +}).passthrough() + +export type RevenueBusinessLineQueryParams = z.infer + +export const RevenueBusinessLineDataSchema = z.object({ + period_ending: z.string().describe('The end date of the reporting period.'), + fiscal_period: z.string().nullable().default(null).describe('The fiscal period of the reporting period.'), + fiscal_year: z.number().nullable().default(null).describe('The fiscal year of the reporting period.'), + filing_date: z.string().nullable().default(null).describe('The filing date of the report.'), + business_line: z.string().nullable().default(null).describe('The business line represented by the revenue data.'), + revenue: z.number().describe('The total revenue attributed to the business line.'), +}).passthrough() + +export type RevenueBusinessLineData = z.infer diff --git a/packages/opentypebb/src/standard-models/revenue-geographic.ts b/packages/opentypebb/src/standard-models/revenue-geographic.ts new file mode 100644 index 00000000..31703b6a --- /dev/null +++ b/packages/opentypebb/src/standard-models/revenue-geographic.ts @@ -0,0 +1,23 @@ +/** + * Revenue by Geographic Segments Standard Model. + * Maps to: openbb_core/provider/standard_models/revenue_geographic.py + */ + +import { z } from 'zod' + +export const RevenueGeographicQueryParamsSchema = z.object({ + symbol: z.string().describe('Symbol to get data for.'), +}).passthrough() + +export type RevenueGeographicQueryParams = z.infer + +export const RevenueGeographicDataSchema = z.object({ + period_ending: z.string().describe('The end date of the reporting period.'), + fiscal_period: z.string().nullable().default(null).describe('The fiscal period of the reporting period.'), + fiscal_year: z.number().nullable().default(null).describe('The fiscal year of the reporting period.'), + filing_date: z.string().nullable().default(null).describe('The filing date of the report.'), + region: z.string().nullable().default(null).describe('The region represented by the revenue data.'), + revenue: z.number().describe('The total revenue attributed to the region.'), +}).passthrough() + +export type RevenueGeographicData = z.infer diff --git a/packages/opentypebb/src/standard-models/risk-premium.ts b/packages/opentypebb/src/standard-models/risk-premium.ts new file mode 100644 index 00000000..fb20092c --- /dev/null +++ b/packages/opentypebb/src/standard-models/risk-premium.ts @@ -0,0 +1,19 @@ +/** + * Risk Premium Standard Model. + * Maps to: openbb_core/provider/standard_models/risk_premium.py + */ + +import { z } from 'zod' + +export const RiskPremiumQueryParamsSchema = z.object({}).passthrough() + +export type RiskPremiumQueryParams = z.infer + +export const RiskPremiumDataSchema = z.object({ + country: z.string().describe('Market country.'), + continent: z.string().nullable().default(null).describe('Continent of the country.'), + total_equity_risk_premium: z.number().nullable().default(null).describe('Total equity risk premium for the country.'), + country_risk_premium: z.number().nullable().default(null).describe('Country-specific risk premium.'), +}).passthrough() + +export type RiskPremiumData = z.infer diff --git a/packages/opentypebb/src/standard-models/share-price-index.ts b/packages/opentypebb/src/standard-models/share-price-index.ts new file mode 100644 index 00000000..a3761c15 --- /dev/null +++ b/packages/opentypebb/src/standard-models/share-price-index.ts @@ -0,0 +1,22 @@ +/** + * Share Price Index Standard Model. + */ + +import { z } from 'zod' + +export const SharePriceIndexQueryParamsSchema = z.object({ + country: z.string().default('united_states').describe('Country to get share price index for.'), + frequency: z.enum(['annual', 'quarter', 'monthly']).default('monthly').describe('Data frequency.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type SharePriceIndexQueryParams = z.infer + +export const SharePriceIndexDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + country: z.string().nullable().default(null).describe('Country name.'), + value: z.number().nullable().default(null).describe('Share price index value.'), +}).passthrough() + +export type SharePriceIndexData = z.infer diff --git a/packages/opentypebb/src/standard-models/share-statistics.ts b/packages/opentypebb/src/standard-models/share-statistics.ts new file mode 100644 index 00000000..aca57db7 --- /dev/null +++ b/packages/opentypebb/src/standard-models/share-statistics.ts @@ -0,0 +1,22 @@ +/** + * Share Statistics Standard Model. + * Maps to: standard_models/share_statistics.py + */ + +import { z } from 'zod' + +export const ShareStatisticsQueryParamsSchema = z.object({ + symbol: z.string().transform(v => v.toUpperCase()).describe('Symbol to get data for.'), +}) +export type ShareStatisticsQueryParams = z.infer + +const numOrNull = z.number().nullable().default(null) + +export const ShareStatisticsDataSchema = z.object({ + symbol: z.string().describe('Symbol representing the entity requested in the data.'), + date: z.string().nullable().default(null).describe('The date of the data.'), + free_float: numOrNull.describe('Percentage of unrestricted shares of a publicly-traded company.'), + float_shares: numOrNull.describe('Number of shares available for trading by the general public.'), + outstanding_shares: numOrNull.describe('Total number of shares of a publicly-traded company.'), +}).passthrough() +export type ShareStatisticsData = z.infer diff --git a/packages/opentypebb/src/standard-models/short-term-energy-outlook.ts b/packages/opentypebb/src/standard-models/short-term-energy-outlook.ts new file mode 100644 index 00000000..b427118d --- /dev/null +++ b/packages/opentypebb/src/standard-models/short-term-energy-outlook.ts @@ -0,0 +1,30 @@ +/** + * Short-Term Energy Outlook Standard Model. + * Data from EIA STEO reports. + */ + +import { z } from 'zod' + +export const ShortTermEnergyOutlookQueryParamsSchema = z.object({ + category: z.enum([ + 'crude_oil_price', + 'gasoline_price', + 'natural_gas_price', + 'crude_oil_production', + 'petroleum_consumption', + ]).default('crude_oil_price').describe('STEO data category.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type ShortTermEnergyOutlookQueryParams = z.infer + +export const ShortTermEnergyOutlookDataSchema = z.object({ + date: z.string().describe('Observation date.'), + value: z.number().nullable().default(null).describe('Observation value.'), + category: z.string().nullable().default(null).describe('Data category.'), + unit: z.string().nullable().default(null).describe('Unit of measurement.'), + forecast: z.boolean().nullable().default(null).describe('Whether this is a forecast value.'), +}).passthrough() + +export type ShortTermEnergyOutlookData = z.infer diff --git a/packages/opentypebb/src/standard-models/sloos.ts b/packages/opentypebb/src/standard-models/sloos.ts new file mode 100644 index 00000000..5939bf5c --- /dev/null +++ b/packages/opentypebb/src/standard-models/sloos.ts @@ -0,0 +1,21 @@ +/** + * Senior Loan Officer Opinion Survey (SLOOS) Standard Model. + * Maps to: openbb_core/provider/standard_models/sloos.py + */ + +import { z } from 'zod' + +export const SloosQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type SloosQueryParams = z.infer + +export const SloosDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + ci_loan_tightening: z.number().nullable().default(null).describe('Net % tightening standards for C&I loans to large firms.'), + consumer_loan_tightening: z.number().nullable().default(null).describe('Net % tightening standards for consumer loans.'), +}).passthrough() + +export type SloosData = z.infer diff --git a/packages/opentypebb/src/standard-models/sp500-multiples.ts b/packages/opentypebb/src/standard-models/sp500-multiples.ts new file mode 100644 index 00000000..0e3b62ed --- /dev/null +++ b/packages/opentypebb/src/standard-models/sp500-multiples.ts @@ -0,0 +1,22 @@ +/** + * SP500 Multiples Standard Model. + * Maps to: openbb_core/provider/standard_models/sp500_multiples.py + */ + +import { z } from 'zod' + +export const SP500MultiplesQueryParamsSchema = z.object({ + series_name: z.string().default('pe_month').describe('The name of the series. Defaults to pe_month.'), + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), +}).passthrough() + +export type SP500MultiplesQueryParams = z.infer + +export const SP500MultiplesDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + name: z.string().describe('Name of the series.'), + value: z.number().describe('Value of the series.'), +}).passthrough() + +export type SP500MultiplesData = z.infer diff --git a/packages/opentypebb/src/standard-models/total-factor-productivity.ts b/packages/opentypebb/src/standard-models/total-factor-productivity.ts new file mode 100644 index 00000000..94eb524c --- /dev/null +++ b/packages/opentypebb/src/standard-models/total-factor-productivity.ts @@ -0,0 +1,20 @@ +/** + * Total Factor Productivity Standard Model. + * Maps to: openbb_core/provider/standard_models/total_factor_productivity.py + */ + +import { z } from 'zod' + +export const TotalFactorProductivityQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type TotalFactorProductivityQueryParams = z.infer + +export const TotalFactorProductivityDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + value: z.number().nullable().default(null).describe('Total factor productivity value.'), +}).passthrough() + +export type TotalFactorProductivityData = z.infer diff --git a/packages/opentypebb/src/standard-models/treasury-rates.ts b/packages/opentypebb/src/standard-models/treasury-rates.ts new file mode 100644 index 00000000..3f8af7ba --- /dev/null +++ b/packages/opentypebb/src/standard-models/treasury-rates.ts @@ -0,0 +1,34 @@ +/** + * Treasury Rates Standard Model. + * Maps to: openbb_core/provider/standard_models/treasury_rates.py + */ + +import { z } from 'zod' + +const rateField = z.number().nullable().default(null) + +export const TreasuryRatesQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), +}).passthrough() + +export type TreasuryRatesQueryParams = z.infer + +export const TreasuryRatesDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + week_4: rateField.describe('4 week Treasury bills rate.'), + month_1: rateField.describe('1 month Treasury rate.'), + month_2: rateField.describe('2 month Treasury rate.'), + month_3: rateField.describe('3 month Treasury rate.'), + month_6: rateField.describe('6 month Treasury rate.'), + year_1: rateField.describe('1 year Treasury rate.'), + year_2: rateField.describe('2 year Treasury rate.'), + year_3: rateField.describe('3 year Treasury rate.'), + year_5: rateField.describe('5 year Treasury rate.'), + year_7: rateField.describe('7 year Treasury rate.'), + year_10: rateField.describe('10 year Treasury rate.'), + year_20: rateField.describe('20 year Treasury rate.'), + year_30: rateField.describe('30 year Treasury rate.'), +}).passthrough() + +export type TreasuryRatesData = z.infer diff --git a/packages/opentypebb/src/standard-models/unemployment.ts b/packages/opentypebb/src/standard-models/unemployment.ts new file mode 100644 index 00000000..41314825 --- /dev/null +++ b/packages/opentypebb/src/standard-models/unemployment.ts @@ -0,0 +1,23 @@ +/** + * Unemployment Standard Model. + * Maps to: openbb_core/provider/standard_models/unemployment.py + */ + +import { z } from 'zod' + +export const UnemploymentQueryParamsSchema = z.object({ + country: z.string().default('united_states').describe('The country to get data for.'), + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), + frequency: z.enum(['annual', 'quarter', 'monthly']).default('monthly').describe('Data frequency.'), +}).passthrough() + +export type UnemploymentQueryParams = z.infer + +export const UnemploymentDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + country: z.string().nullable().default(null).describe('Country name.'), + value: z.number().nullable().default(null).describe('Unemployment rate value (percent).'), +}).passthrough() + +export type UnemploymentData = z.infer diff --git a/packages/opentypebb/src/standard-models/university-of-michigan.ts b/packages/opentypebb/src/standard-models/university-of-michigan.ts new file mode 100644 index 00000000..9b47141b --- /dev/null +++ b/packages/opentypebb/src/standard-models/university-of-michigan.ts @@ -0,0 +1,24 @@ +/** + * University of Michigan Consumer Sentiment Standard Model. + * Maps to: openbb_core/provider/standard_models/university_of_michigan.py + */ + +import { z } from 'zod' + +export const UniversityOfMichiganQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date in YYYY-MM-DD.'), + end_date: z.string().nullable().default(null).describe('End date in YYYY-MM-DD.'), +}).passthrough() + +export type UniversityOfMichiganQueryParams = z.infer + +export const UniversityOfMichiganDataSchema = z.object({ + date: z.string().describe('The date of the data.'), + consumer_sentiment: z.number().nullable().default(null).describe('Index of Consumer Sentiment.'), + current_conditions: z.number().nullable().default(null).describe('Index of Current Economic Conditions.'), + expectations: z.number().nullable().default(null).describe('Index of Consumer Expectations.'), + inflation_expectation_1y: z.number().nullable().default(null).describe('Median expected price change next 12 months (%).'), + inflation_expectation_5y: z.number().nullable().default(null).describe('Median expected price change next 5 years (%).'), +}).passthrough() + +export type UniversityOfMichiganData = z.infer diff --git a/packages/opentypebb/src/standard-models/world-news.ts b/packages/opentypebb/src/standard-models/world-news.ts new file mode 100644 index 00000000..f8030db3 --- /dev/null +++ b/packages/opentypebb/src/standard-models/world-news.ts @@ -0,0 +1,26 @@ +/** + * World News Standard Model. + * Maps to: openbb_core/provider/standard_models/world_news.py + */ + +import { z } from 'zod' + +export const WorldNewsQueryParamsSchema = z.object({ + start_date: z.string().nullable().default(null).describe('Start date of the data, in YYYY-MM-DD format.'), + end_date: z.string().nullable().default(null).describe('End date of the data, in YYYY-MM-DD format.'), + limit: z.number().int().nonnegative().nullable().default(null).describe('The number of data entries to return.'), +}).passthrough() + +export type WorldNewsQueryParams = z.infer + +export const WorldNewsDataSchema = z.object({ + date: z.string().describe('The date of publication.'), + title: z.string().describe('Title of the article.'), + author: z.string().nullable().default(null).describe('Author of the article.'), + excerpt: z.string().nullable().default(null).describe('Excerpt of the article text.'), + body: z.string().nullable().default(null).describe('Body of the article text.'), + images: z.unknown().nullable().default(null).describe('Images associated with the article.'), + url: z.string().nullable().default(null).describe('URL to the article.'), +}).passthrough() + +export type WorldNewsData = z.infer diff --git a/packages/opentypebb/tsconfig.json b/packages/opentypebb/tsconfig.json new file mode 100644 index 00000000..87332f7d --- /dev/null +++ b/packages/opentypebb/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "outDir": "dist", + "rootDir": ".", + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/opentypebb/tsup.config.ts b/packages/opentypebb/tsup.config.ts new file mode 100644 index 00000000..c7e5cd50 --- /dev/null +++ b/packages/opentypebb/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: { + index: 'src/index.ts', + server: 'src/server.ts', + }, + format: ['esm'], + dts: true, + clean: true, + target: 'node20', + splitting: true, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0a79b7e..773857fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@alpacahq/alpaca-trade-api': specifier: ^3.1.3 version: 3.1.3 + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.2.72 + version: 0.2.72(zod@4.3.6) '@grammyjs/auto-retry': specifier: ^2.0.2 version: 2.0.2(grammy@1.40.0) @@ -62,6 +65,9 @@ importers: json5: specifier: ^2.2.3 version: 2.2.3 + opentypebb: + specifier: link:./packages/opentypebb + version: link:packages/opentypebb pino: specifier: ^10.3.1 version: 10.3.1 @@ -149,6 +155,12 @@ packages: resolution: {integrity: sha512-0b0mAvxaxh1JVoX70g0/Pw28QT+MZdDbvpu+xkf3ZZUT8iYpMVacrB0nWA1qKSM0inwzrcDlVn9uSunOL1wmNQ==} engines: {node: '>=16.9', npm: '>=6'} + '@anthropic-ai/claude-agent-sdk@0.2.72': + resolution: {integrity: sha512-GR3QaLRCoWO5DkRknaaCH6zzmUNZ3E6VckEKNE7EO5R7qDBexQe9tDKag257pji2NenTrnBDMxznoZrhNCRTzA==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^4.0.0 + '@borewit/text-codec@0.2.1': resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} @@ -2044,6 +2056,20 @@ snapshots: - supports-color - utf-8-validate + '@anthropic-ai/claude-agent-sdk@0.2.72(zod@4.3.6)': + dependencies: + zod: 4.3.6 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + '@borewit/text-codec@0.2.1': {} '@emnapi/runtime@1.8.1': diff --git a/src/ai-providers/agent-sdk/agent-sdk-provider.ts b/src/ai-providers/agent-sdk/agent-sdk-provider.ts new file mode 100644 index 00000000..cd466036 --- /dev/null +++ b/src/ai-providers/agent-sdk/agent-sdk-provider.ts @@ -0,0 +1,95 @@ +/** + * AgentSdkProvider — GenerateProvider backed by @anthropic-ai/claude-agent-sdk. + * + * Slim data-source adapter: only calls the Agent SDK and yields ProviderEvents. + * Session management (append, compact, persist) lives in AgentCenter. + * + * Reuses agent.json's `claudeCode` config block (allowedTools, disallowedTools, maxTurns) + * since both providers are backed by the same Claude Code CLI. + */ + +import { resolve } from 'node:path' +import type { Tool } from 'ai' +import type { ProviderResult, ProviderEvent } from '../../core/ai-provider.js' +import type { GenerateProvider, GenerateInput, GenerateOpts } from '../../core/ai-provider.js' +import type { AgentSdkConfig, AgentSdkOverride } from './query.js' +import { readAgentConfig } from '../../core/config.js' +import { extractMediaFromToolResultContent } from '../../core/media.js' +import { createChannel } from '../../core/async-channel.js' +import { askAgentSdk } from './query.js' +import { buildAgentSdkMcpServer } from './tool-bridge.js' + +export class AgentSdkProvider implements GenerateProvider { + readonly inputKind = 'text' as const + + constructor( + private getTools: () => Promise>, + private systemPrompt?: string, + ) {} + + /** Re-read agent config from disk to pick up hot-reloaded settings. */ + private async resolveConfig(): Promise { + const agent = await readAgentConfig() + return { + ...agent.claudeCode, + evolutionMode: agent.evolutionMode, + cwd: agent.evolutionMode ? process.cwd() : resolve('data/brain'), + } + } + + /** Build an in-process MCP server from ToolCenter, filtering disabled tools. */ + private async buildMcpServer(disabledTools?: string[]) { + const tools = await this.getTools() + return buildAgentSdkMcpServer(tools, disabledTools) + } + + async ask(prompt: string): Promise { + const config = await this.resolveConfig() + const mcpServer = await this.buildMcpServer() + const result = await askAgentSdk(prompt, config, undefined, mcpServer) + return { text: result.text, media: [] } + } + + async *generate(input: GenerateInput, opts?: GenerateOpts): AsyncGenerator { + if (input.kind !== 'text') throw new Error('AgentSdkProvider expects text input') + + const config = await this.resolveConfig() + const agentSdkConfig: AgentSdkConfig = { + ...config, + ...(opts?.disabledTools?.length + ? { disallowedTools: [...(config.disallowedTools ?? []), ...opts.disabledTools] } + : {}), + systemPrompt: input.systemPrompt ?? this.systemPrompt, + } + + const override: AgentSdkOverride | undefined = opts?.agentSdk + const mcpServer = await this.buildMcpServer(opts?.disabledTools) + + const channel = createChannel() + const media: import('../../core/types.js').MediaAttachment[] = [] + + const resultPromise = askAgentSdk( + input.prompt, + { + ...agentSdkConfig, + onToolUse: ({ id, name, input: toolInput }) => { + channel.push({ type: 'tool_use', id, name, input: toolInput }) + }, + onToolResult: ({ toolUseId, content }) => { + media.push(...extractMediaFromToolResultContent(content)) + channel.push({ type: 'tool_result', tool_use_id: toolUseId, content }) + }, + }, + override, + mcpServer, + ) + + resultPromise.then(() => channel.close()).catch((err) => channel.error(err instanceof Error ? err : new Error(String(err)))) + yield* channel + + const result = await resultPromise + const prefix = result.ok ? '' : '[error] ' + yield { type: 'done', result: { text: prefix + result.text, media } } + } + +} diff --git a/src/ai-providers/agent-sdk/query.ts b/src/ai-providers/agent-sdk/query.ts new file mode 100644 index 00000000..17581720 --- /dev/null +++ b/src/ai-providers/agent-sdk/query.ts @@ -0,0 +1,231 @@ +/** + * Agent SDK query wrapper — encapsulates `query()` with env injection and result collection. + * + * API key comes from `readAIProviderConfig().apiKeys.anthropic`, injected via + * `env: { ANTHROPIC_API_KEY }`. Per-channel overrides take precedence. + */ + +import { query as sdkQuery } from '@anthropic-ai/claude-agent-sdk' +import type { McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent-sdk' +import { pino } from 'pino' +import type { ContentBlock } from '../../core/session.js' +import { readAIProviderConfig } from '../../core/config.js' +import { logToolCall } from '../log-tool-call.js' + +const logger = pino({ + transport: { target: 'pino/file', options: { destination: 'logs/agent-sdk.log', mkdir: true } }, +}) + +// ==================== Types ==================== + +export interface AgentSdkConfig { + allowedTools?: string[] + disallowedTools?: string[] + evolutionMode?: boolean + maxTurns?: number + cwd?: string + systemPrompt?: string + appendSystemPrompt?: string + /** Called for each tool_use block in the stream. */ + onToolUse?: (toolUse: { id: string; name: string; input: unknown }) => void + /** Called for each tool_result in the stream. */ + onToolResult?: (toolResult: { toolUseId: string; content: string }) => void +} + +export interface AgentSdkOverride { + model?: string + apiKey?: string + baseUrl?: string +} + +export interface AgentSdkMessage { + role: 'assistant' | 'user' + content: ContentBlock[] +} + +export interface AgentSdkResult { + text: string + ok: boolean + messages: AgentSdkMessage[] +} + +// ==================== Tool lists ==================== + +const NORMAL_ALLOWED_TOOLS = [ + 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebSearch', 'WebFetch', + 'mcp__open-alice__*', +] + +const EVOLUTION_ALLOWED_TOOLS = [ + 'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep', 'WebSearch', 'WebFetch', + 'mcp__open-alice__*', +] + +const NORMAL_EXTRA_DISALLOWED = ['Bash'] +const EVOLUTION_EXTRA_DISALLOWED: string[] = [] + +// ==================== Strip image data ==================== + +function stripImageData(raw: string): string { + try { + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return raw + let changed = false + const cleaned = parsed.map((item: Record) => { + if (item.type === 'image' && (item.source as Record)?.data) { + changed = true + return { type: 'text', text: '[Image saved to disk — use Read tool to view the file]' } + } + return item + }) + return changed ? JSON.stringify(cleaned) : raw + } catch { return raw } +} + +// ==================== Public ==================== + +/** + * Call Agent SDK `query()` and collect the result. + * + * Each invocation is independent (persistSession: false). The caller manages + * session persistence via SessionStore, matching the Claude Code CLI provider pattern. + */ +export async function askAgentSdk( + prompt: string, + config: AgentSdkConfig = {}, + override?: AgentSdkOverride, + mcpServer?: McpSdkServerConfigWithInstance, +): Promise { + const { + allowedTools = [], + disallowedTools = [], + evolutionMode = false, + maxTurns = 20, + cwd = process.cwd(), + systemPrompt, + onToolUse, + onToolResult, + } = config + + // Merge: explicit config overrides mode defaults + const modeAllowed = evolutionMode ? EVOLUTION_ALLOWED_TOOLS : NORMAL_ALLOWED_TOOLS + const modeDisallowed = evolutionMode ? EVOLUTION_EXTRA_DISALLOWED : NORMAL_EXTRA_DISALLOWED + const finalAllowed = allowedTools.length > 0 ? allowedTools : modeAllowed + const finalDisallowed = [...disallowedTools, ...modeDisallowed] + + // Build env with API key injection + const aiConfig = await readAIProviderConfig() + const apiKey = override?.apiKey ?? aiConfig.apiKeys.anthropic + const baseUrl = override?.baseUrl ?? aiConfig.baseUrl + const env: Record = { ...process.env } + if (apiKey) env.ANTHROPIC_API_KEY = apiKey + if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl + + // MCP servers + const mcpServers: Record = {} + if (mcpServer) { + mcpServers['open-alice'] = mcpServer + } + + const messages: AgentSdkMessage[] = [] + let resultText = '' + let ok = true + + try { + for await (const event of sdkQuery({ + prompt, + options: { + cwd, + env, + model: override?.model ?? aiConfig.model, + maxTurns, + allowedTools: finalAllowed, + disallowedTools: finalDisallowed, + mcpServers, + systemPrompt, + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + persistSession: false, + }, + })) { + // assistant message — extract tool_use + text blocks + if (event.type === 'assistant' && 'message' in event) { + const msg = (event as any).message + if (msg?.content) { + const blocks: ContentBlock[] = [] + for (const block of msg.content) { + if (block.type === 'tool_use') { + logToolCall(block.name, block.input) + logger.info({ tool: block.name, input: block.input }, 'tool_use') + blocks.push({ type: 'tool_use', id: block.id, name: block.name, input: block.input }) + onToolUse?.({ id: block.id, name: block.name, input: block.input }) + } else if (block.type === 'text') { + blocks.push({ type: 'text', text: block.text }) + } + } + if (blocks.length > 0) { + messages.push({ role: 'assistant', content: blocks }) + } + } + } + + // user message — extract tool_result blocks + else if (event.type === 'user' && 'message' in event) { + const msg = (event as any).message + const content = msg?.content + if (Array.isArray(content)) { + const blocks: ContentBlock[] = [] + for (const block of content) { + if (block.type === 'tool_result') { + const raw = typeof block.content === 'string' + ? block.content + : JSON.stringify(block.content ?? '') + const sessionContent = stripImageData(raw) + logger.info({ toolUseId: block.tool_use_id, content: sessionContent.slice(0, 500) }, 'tool_result') + blocks.push({ type: 'tool_result', tool_use_id: block.tool_use_id, content: sessionContent }) + onToolResult?.({ toolUseId: block.tool_use_id, content: raw }) + } + } + if (blocks.length > 0) { + messages.push({ role: 'user', content: blocks }) + } + } + } + + // result — final text + else if (event.type === 'result') { + const result = event as any + if (result.subtype === 'success') { + resultText = result.result ?? '' + } else { + ok = false + resultText = result.errors?.join('\n') ?? `Agent SDK error: ${result.subtype}` + } + logger.info({ subtype: result.subtype, turns: result.num_turns, durationMs: result.duration_ms }, 'result') + } + } + } catch (err) { + logger.error({ error: String(err) }, 'query_error') + ok = false + resultText = `Agent SDK error: ${err}` + } + + // Fallback: if result is empty, extract last assistant text + if (!resultText && ok) { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'assistant') { + resultText = messages[i].content + .filter((b): b is { type: 'text'; text: string } => b.type === 'text') + .map(b => b.text) + .join('\n') + if (resultText) break + } + } + } + + return { + text: resultText || '(no output)', + ok, + messages, + } +} diff --git a/src/ai-providers/agent-sdk/tool-bridge.ts b/src/ai-providers/agent-sdk/tool-bridge.ts new file mode 100644 index 00000000..ebe898d1 --- /dev/null +++ b/src/ai-providers/agent-sdk/tool-bridge.ts @@ -0,0 +1,82 @@ +/** + * Tool bridge — converts ToolCenter's Vercel AI SDK tools to an Agent SDK MCP server. + * + * Reuses the same pattern as `src/plugins/mcp.ts` (extract .shape, wrap execute), + * but targets `createSdkMcpServer()` instead of `@modelcontextprotocol/sdk McpServer`. + */ + +import { randomUUID } from 'node:crypto' +import { tool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk' +import type { Tool } from 'ai' + +type McpContent = + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string } + +/** + * Convert a Vercel AI SDK tool result to MCP content blocks. + * Handles both plain values and OpenClaw AgentToolResult `{ content: [...] }` format. + */ +function toMcpContent(result: unknown): McpContent[] { + if ( + result != null && + typeof result === 'object' && + 'content' in result && + Array.isArray((result as { content: unknown }).content) + ) { + const items = (result as { content: Array> }).content + const blocks: McpContent[] = [] + for (const item of items) { + if (item.type === 'image' && typeof item.data === 'string' && typeof item.mimeType === 'string') { + blocks.push({ type: 'image', data: item.data, mimeType: item.mimeType }) + } else if (item.type === 'text' && typeof item.text === 'string') { + blocks.push({ type: 'text', text: item.text }) + } else { + blocks.push({ type: 'text', text: JSON.stringify(item) }) + } + } + if ('details' in result && (result as { details: unknown }).details != null) { + blocks.push({ type: 'text', text: JSON.stringify((result as { details: unknown }).details) }) + } + return blocks.length > 0 ? blocks : [{ type: 'text', text: JSON.stringify(result) }] + } + return [{ type: 'text', text: JSON.stringify(result) }] +} + +/** + * Build an Agent SDK MCP server from a Vercel AI SDK tool map. + * + * @param tools Record from ToolCenter.getVercelTools() + * @param disabledTools Optional list of tool names to exclude + * @returns McpSdkServerConfigWithInstance ready for `query({ options: { mcpServers } })` + */ +export function buildAgentSdkMcpServer( + tools: Record, + disabledTools?: string[], +) { + const disabledSet = new Set(disabledTools ?? []) + + const sdkTools = Object.entries(tools) + .filter(([name, t]) => t.execute && !disabledSet.has(name)) + .map(([name, t]) => { + // Extract Zod raw shape — same approach as mcp.ts line 76 + const shape = (t.inputSchema as any)?.shape ?? {} + + return tool(name, t.description ?? name, shape, async (args: any) => { + try { + const result = await t.execute!(args, { + toolCallId: randomUUID(), + messages: [], + }) + return { content: toMcpContent(result) } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } + } + }) + }) + + return createSdkMcpServer({ name: 'open-alice', tools: sdkTools }) +} diff --git a/src/ai-providers/claude-code/claude-code-provider.ts b/src/ai-providers/claude-code/claude-code-provider.ts index efc1f358..4832b8d8 100644 --- a/src/ai-providers/claude-code/claude-code-provider.ts +++ b/src/ai-providers/claude-code/claude-code-provider.ts @@ -1,25 +1,26 @@ /** - * ClaudeCodeProvider — AIProvider implementation backed by the Claude Code CLI. + * ClaudeCodeProvider — GenerateProvider backed by the Claude Code CLI. * - * Thin adapter: delegates to askClaudeCodeWithSession which owns the full - * session management flow (append → compact → build → call CLI → persist). + * Slim data-source adapter: only calls the CLI and yields ProviderEvents. + * Session management (append, compact, persist) lives in AgentCenter. * * Agent config (evolutionMode, allowedTools, disallowedTools) is re-read from * disk on every request so that Web UI changes take effect without restart. */ import { resolve } from 'node:path' -import type { AIProvider, AskOptions, ProviderResult } from '../../core/ai-provider.js' -import type { SessionStore } from '../../core/session.js' -import type { CompactionConfig } from '../../core/compaction.js' +import type { ProviderResult, ProviderEvent } from '../../core/ai-provider.js' +import type { GenerateProvider, GenerateInput, GenerateOpts } from '../../core/ai-provider.js' import type { ClaudeCodeConfig } from './types.js' import { readAgentConfig } from '../../core/config.js' +import { extractMediaFromToolResultContent } from '../../core/media.js' +import { createChannel } from '../../core/async-channel.js' import { askClaudeCode } from './provider.js' -import { askClaudeCodeWithSession } from './session.js' -export class ClaudeCodeProvider implements AIProvider { +export class ClaudeCodeProvider implements GenerateProvider { + readonly inputKind = 'text' as const + constructor( - private compaction: CompactionConfig, private systemPrompt?: string, ) {} @@ -39,13 +40,38 @@ export class ClaudeCodeProvider implements AIProvider { return { text: result.text, media: [] } } - async askWithSession(prompt: string, session: SessionStore, opts?: AskOptions): Promise { + async *generate(input: GenerateInput, opts?: GenerateOpts): AsyncGenerator { + if (input.kind !== 'text') throw new Error('ClaudeCodeProvider expects text input') + const config = await this.resolveConfig() - return askClaudeCodeWithSession(prompt, session, { - claudeCode: config, - compaction: this.compaction, - ...opts, - systemPrompt: opts?.systemPrompt ?? this.systemPrompt, + const claudeCode: ClaudeCodeConfig = { + ...config, + ...(opts?.disabledTools?.length + ? { disallowedTools: [...(config.disallowedTools ?? []), ...opts.disabledTools] } + : {}), + systemPrompt: input.systemPrompt ?? this.systemPrompt, + } + + const channel = createChannel() + const media: import('../../core/types.js').MediaAttachment[] = [] + + const resultPromise = askClaudeCode(input.prompt, { + ...claudeCode, + onToolUse: ({ id, name, input: toolInput }) => { + channel.push({ type: 'tool_use', id, name, input: toolInput }) + }, + onToolResult: ({ toolUseId, content }) => { + media.push(...extractMediaFromToolResultContent(content)) + channel.push({ type: 'tool_result', tool_use_id: toolUseId, content }) + }, }) + + resultPromise.then(() => channel.close()).catch((err) => channel.error(err instanceof Error ? err : new Error(String(err)))) + yield* channel + + const result = await resultPromise + const prefix = result.ok ? '' : '[error] ' + yield { type: 'done', result: { text: prefix + result.text, media } } } + } diff --git a/src/ai-providers/claude-code/index.ts b/src/ai-providers/claude-code/index.ts index 2e709a23..0876c909 100644 --- a/src/ai-providers/claude-code/index.ts +++ b/src/ai-providers/claude-code/index.ts @@ -1,5 +1,3 @@ export { askClaudeCode } from './provider.js' export type { ClaudeCodeConfig, ClaudeCodeResult, ClaudeCodeMessage } from './types.js' -export { askClaudeCodeWithSession } from './session.js' -export type { ClaudeCodeSessionConfig, ClaudeCodeSessionResult } from './session.js' export { ClaudeCodeProvider } from './claude-code-provider.js' diff --git a/src/ai-providers/claude-code/provider.ts b/src/ai-providers/claude-code/provider.ts index 31ca3c62..72aa9f97 100644 --- a/src/ai-providers/claude-code/provider.ts +++ b/src/ai-providers/claude-code/provider.ts @@ -62,6 +62,7 @@ export async function askClaudeCode( cwd = process.cwd(), systemPrompt, appendSystemPrompt, + onToolUse, onToolResult, } = config @@ -127,6 +128,7 @@ export async function askClaudeCode( logToolCall(block.name, block.input) logger.info({ tool: block.name, input: block.input }, 'tool_use') blocks.push({ type: 'tool_use', id: block.id, name: block.name, input: block.input }) + onToolUse?.({ id: block.id, name: block.name, input: block.input }) } else if (block.type === 'text') { blocks.push({ type: 'text', text: block.text }) } diff --git a/src/ai-providers/claude-code/session.ts b/src/ai-providers/claude-code/session.ts deleted file mode 100644 index 342125e4..00000000 --- a/src/ai-providers/claude-code/session.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { SessionStore } from '../../core/session.js' -import type { CompactionConfig } from '../../core/compaction.js' -import type { MediaAttachment } from '../../core/types.js' -import type { ClaudeCodeConfig } from './types.js' -import { toTextHistory } from '../../core/session.js' -import { compactIfNeeded } from '../../core/compaction.js' -import { extractMediaFromToolResultContent } from '../../core/media.js' -import { askClaudeCode } from './provider.js' - -// ==================== Types ==================== - -export interface ClaudeCodeSessionConfig { - /** Config passed through to askClaudeCode (allowedTools, disallowedTools, maxTurns, etc.). */ - claudeCode: ClaudeCodeConfig - /** Compaction config for auto-summarization. */ - compaction: CompactionConfig - /** Optional system prompt (passed to claude CLI --system-prompt). */ - systemPrompt?: string - /** Max text history entries to include in . Default: 50. */ - maxHistoryEntries?: number - /** Preamble text inside block. */ - historyPreamble?: string -} - -export interface ClaudeCodeSessionResult { - text: string - media: MediaAttachment[] -} - -// ==================== Default ==================== - -const DEFAULT_MAX_HISTORY = 50 -const DEFAULT_PREAMBLE = - 'The following is the recent conversation history. Use it as context if it references earlier events or decisions.' - -// ==================== Public ==================== - -/** - * Call Claude Code CLI with full session management: - * append user message → compact → build history prompt → call → persist messages. - * - * The raw `askClaudeCode` remains available for stateless one-shot calls (e.g. compaction callbacks). - */ -export async function askClaudeCodeWithSession( - prompt: string, - session: SessionStore, - config: ClaudeCodeSessionConfig, -): Promise { - const maxHistory = config.maxHistoryEntries ?? DEFAULT_MAX_HISTORY - const preamble = config.historyPreamble ?? DEFAULT_PREAMBLE - - // 1. Append user message to session - await session.appendUser(prompt, 'human') - - // 2. Compact if needed (using askClaudeCode as summarizer) - const compactionResult = await compactIfNeeded( - session, - config.compaction, - async (summarizePrompt) => { - const r = await askClaudeCode(summarizePrompt, { - ...config.claudeCode, - maxTurns: 1, - }) - return r.text - }, - ) - - // 3. Read active window and build text history - const entries = compactionResult.activeEntries ?? await session.readActive() - const textHistory = toTextHistory(entries).slice(-maxHistory) - - // 4. Build full prompt with if history exists - let fullPrompt: string - if (textHistory.length > 0) { - const lines = textHistory.map((entry) => { - const tag = entry.role === 'user' ? 'User' : 'Bot' - return `[${tag}] ${entry.text}` - }) - fullPrompt = [ - '', - preamble, - '', - ...lines, - '', - '', - prompt, - ].join('\n') - } else { - fullPrompt = prompt - } - - // 5. Call askClaudeCode — collect media from tool results - const media: MediaAttachment[] = [] - const result = await askClaudeCode(fullPrompt, { - ...config.claudeCode, - systemPrompt: config.systemPrompt, - onToolResult: ({ content }) => { - media.push(...extractMediaFromToolResultContent(content)) - }, - }) - - // 6. Persist intermediate messages (tool calls + results) to session - for (const msg of result.messages) { - if (msg.role === 'assistant') { - await session.appendAssistant(msg.content, 'claude-code') - } else { - await session.appendUser(msg.content, 'claude-code') - } - } - - // 7. Return unified result - const prefix = result.ok ? '' : '[error] ' - return { text: prefix + result.text, media } -} diff --git a/src/ai-providers/claude-code/types.ts b/src/ai-providers/claude-code/types.ts index d6f2292c..2fcf9725 100644 --- a/src/ai-providers/claude-code/types.ts +++ b/src/ai-providers/claude-code/types.ts @@ -15,6 +15,11 @@ export interface ClaudeCodeConfig { systemPrompt?: string /** Append to Claude Code's default system prompt. */ appendSystemPrompt?: string + /** + * Called for each tool_use block in the JSONL stream. + * Use this to stream tool call events to consumers. + */ + onToolUse?: (toolUse: { id: string; name: string; input: unknown }) => void /** * Called for each tool_result block in the JSONL stream. * Use this to extract side-channel data (e.g. images) from tool results. diff --git a/src/ai-providers/vercel-ai-sdk/vercel-provider.ts b/src/ai-providers/vercel-ai-sdk/vercel-provider.ts index 961edf14..346e89da 100644 --- a/src/ai-providers/vercel-ai-sdk/vercel-provider.ts +++ b/src/ai-providers/vercel-ai-sdk/vercel-provider.ts @@ -1,5 +1,5 @@ /** - * VercelAIProvider — AIProvider implementation backed by Vercel AI SDK's ToolLoopAgent. + * VercelAIProvider — GenerateProvider backed by Vercel AI SDK's ToolLoopAgent. * * The model is lazily created from config and cached. When model.json or * api-keys.json changes on disk, the next request picks up the new model @@ -7,45 +7,56 @@ */ import type { ModelMessage, Tool } from 'ai' -import type { AIProvider, AskOptions, ProviderResult } from '../../core/ai-provider.js' +import type { ProviderResult, ProviderEvent } from '../../core/ai-provider.js' +import type { GenerateProvider, GenerateInput, GenerateOpts } from '../../core/ai-provider.js' import type { Agent } from './agent.js' -import type { SessionStore } from '../../core/session.js' -import type { CompactionConfig } from '../../core/compaction.js' import type { MediaAttachment } from '../../core/types.js' -import { toModelMessages } from '../../core/session.js' -import { compactIfNeeded } from '../../core/compaction.js' import { extractMediaFromToolOutput } from '../../core/media.js' -import { createModelFromConfig } from '../../core/model-factory.js' +import { createModelFromConfig, type ModelOverride } from '../../core/model-factory.js' import { createAgent } from './agent.js' +import { createChannel } from '../../core/async-channel.js' -export class VercelAIProvider implements AIProvider { +export class VercelAIProvider implements GenerateProvider { + readonly inputKind = 'messages' as const private cachedKey: string | null = null private cachedToolCount: number = 0 + private cachedSystemPrompt: string | null = null private cachedAgent: Agent | null = null constructor( private getTools: () => Promise>, private instructions: string, private maxSteps: number, - private compaction: CompactionConfig, ) {} - /** Lazily create or return the cached agent, re-creating when config or tools change. */ - private async resolveAgent(): Promise { - const { model, key } = await createModelFromConfig() - const tools = await this.getTools() - const toolCount = Object.keys(tools).length - if (key !== this.cachedKey || toolCount !== this.cachedToolCount) { - this.cachedAgent = createAgent(model, tools, this.instructions, this.maxSteps) + /** Lazily create or return the cached agent, re-creating when config, tools, or system prompt change. */ + private async resolveAgent(systemPrompt?: string, disabledTools?: string[], modelOverride?: ModelOverride): Promise { + const { model, key } = await createModelFromConfig(modelOverride) + const allTools = await this.getTools() + + // Per-channel overrides: skip cache and create a fresh agent + if (disabledTools?.length || modelOverride) { + const disabledSet = disabledTools?.length ? new Set(disabledTools) : null + const tools = disabledSet + ? Object.fromEntries(Object.entries(allTools).filter(([name]) => !disabledSet.has(name))) + : allTools + return createAgent(model, tools, systemPrompt ?? this.instructions, this.maxSteps) + } + + const toolCount = Object.keys(allTools).length + const effectivePrompt = systemPrompt ?? null + if (key !== this.cachedKey || toolCount !== this.cachedToolCount || effectivePrompt !== this.cachedSystemPrompt) { + this.cachedAgent = createAgent(model, allTools, systemPrompt ?? this.instructions, this.maxSteps) this.cachedKey = key this.cachedToolCount = toolCount + this.cachedSystemPrompt = effectivePrompt console.log(`vercel-ai: model loaded → ${key} (${toolCount} tools)`) } return this.cachedAgent! } async ask(prompt: string): Promise { - const agent = await this.resolveAgent() + const agent = await this.resolveAgent(undefined) const media: MediaAttachment[] = [] const result = await agent.generate({ prompt, @@ -58,35 +69,36 @@ export class VercelAIProvider implements AIProvider { return { text: result.text ?? '', media } } - async askWithSession(prompt: string, session: SessionStore, _opts?: AskOptions): Promise { - const agent = await this.resolveAgent() - - await session.appendUser(prompt, 'human') - - const compactionResult = await compactIfNeeded( - session, - this.compaction, - async (summarizePrompt) => { - const r = await agent.generate({ prompt: summarizePrompt }) - return r.text ?? '' - }, - ) + async *generate(input: GenerateInput, opts?: GenerateOpts): AsyncGenerator { + if (input.kind !== 'messages') throw new Error('VercelAIProvider expects messages input') - const entries = compactionResult.activeEntries ?? await session.readActive() - const messages = toModelMessages(entries) + const agent = await this.resolveAgent(input.systemPrompt, opts?.disabledTools, opts?.vercelAiSdk) + const channel = createChannel() const media: MediaAttachment[] = [] - const result = await agent.generate({ - messages: messages as ModelMessage[], + + const resultPromise = agent.generate({ + messages: input.messages as ModelMessage[], onStepFinish: (step) => { + for (const tc of step.toolCalls) { + channel.push({ type: 'tool_use', id: tc.toolCallId, name: tc.toolName, input: tc.input }) + } for (const tr of step.toolResults) { media.push(...extractMediaFromToolOutput(tr.output)) + const content = typeof tr.output === 'string' ? tr.output : JSON.stringify(tr.output ?? '') + channel.push({ type: 'tool_result', tool_use_id: tr.toolCallId, content }) + } + if (step.text) { + channel.push({ type: 'text', text: step.text }) } }, }) - const text = result.text ?? '' - await session.appendAssistant(text, 'engine') - return { text, media } + resultPromise.then(() => channel.close()).catch((err) => channel.error(err instanceof Error ? err : new Error(String(err)))) + yield* channel + + const result = await resultPromise + yield { type: 'done', result: { text: result.text ?? '', media } } } + } diff --git a/src/connectors/mcp-ask/mcp-ask-plugin.ts b/src/connectors/mcp-ask/mcp-ask-plugin.ts index fd8a2547..41a8a8b4 100644 --- a/src/connectors/mcp-ask/mcp-ask-plugin.ts +++ b/src/connectors/mcp-ask/mcp-ask-plugin.ts @@ -59,7 +59,7 @@ export class McpAskPlugin implements Plugin { async ({ message, sessionId }) => { const session = await plugin.getSession(sessionId) - const result = await ctx.engine.askWithSession(message, session, { + const result = await ctx.agentCenter.askWithSession(message, session, { historyPreamble: 'The following is the conversation from an external MCP client. Use it as context if the caller references earlier messages.', }) diff --git a/src/connectors/telegram/telegram-plugin.ts b/src/connectors/telegram/telegram-plugin.ts index 5a9991ca..fa5a2811 100644 --- a/src/connectors/telegram/telegram-plugin.ts +++ b/src/connectors/telegram/telegram-plugin.ts @@ -18,6 +18,7 @@ const MAX_MESSAGE_LENGTH = 4096 const BACKEND_LABELS: Record = { 'claude-code': 'Claude Code', 'vercel-ai-sdk': 'Vercel AI SDK', + 'agent-sdk': 'Agent SDK', } export class TelegramPlugin implements Plugin { @@ -111,9 +112,11 @@ export class TelegramPlugin implements Plugin { // Edit the original settings message in-place const ccLabel = backend === 'claude-code' ? '> Claude Code' : 'Claude Code' const aiLabel = backend === 'vercel-ai-sdk' ? '> Vercel AI SDK' : 'Vercel AI SDK' + const sdkLabel = backend === 'agent-sdk' ? '> Agent SDK' : 'Agent SDK' const keyboard = new InlineKeyboard() .text(ccLabel, 'provider:claude-code') .text(aiLabel, 'provider:vercel-ai-sdk') + .text(sdkLabel, 'provider:agent-sdk') await ctx.editMessageText( `Current provider: ${BACKEND_LABELS[backend]}\n\nChoose default AI provider:`, { reply_markup: keyboard }, @@ -265,9 +268,9 @@ export class TelegramPlugin implements Plugin { const stopTyping = this.startTypingIndicator(message.chatId) try { - // Route through unified provider (Engine → ProviderRouter → Vercel or Claude Code) + // Route through AgentCenter → GenerateRouter → active provider const session = await this.getSession(message.from.id) - const result = await engineCtx.engine.askWithSession(prompt, session, { + const result = await engineCtx.agentCenter.askWithSession(prompt, session, { historyPreamble: 'The following is the recent conversation from this Telegram chat. Use it as context if the user references earlier messages.', }) stopTyping() @@ -320,10 +323,12 @@ export class TelegramPlugin implements Plugin { const aiConfig = await readAIConfig() const ccLabel = aiConfig.backend === 'claude-code' ? '> Claude Code' : 'Claude Code' const aiLabel = aiConfig.backend === 'vercel-ai-sdk' ? '> Vercel AI SDK' : 'Vercel AI SDK' + const sdkLabel = aiConfig.backend === 'agent-sdk' ? '> Agent SDK' : 'Agent SDK' const keyboard = new InlineKeyboard() .text(ccLabel, 'provider:claude-code') .text(aiLabel, 'provider:vercel-ai-sdk') + .text(sdkLabel, 'provider:agent-sdk') await this.bot!.api.sendMessage( chatId, diff --git a/src/connectors/web/routes/channels.ts b/src/connectors/web/routes/channels.ts new file mode 100644 index 00000000..120aaf01 --- /dev/null +++ b/src/connectors/web/routes/channels.ts @@ -0,0 +1,137 @@ +import { Hono } from 'hono' +import { SessionStore } from '../../../core/session.js' +import { readWebSubchannels, writeWebSubchannels } from '../../../core/config.js' +import type { WebChannel } from '../../../core/types.js' +import type { SSEClient } from './chat.js' + +interface ChannelsDeps { + sessions: Map + sseByChannel: Map> +} + +/** Channels CRUD: GET /, POST /, PUT /:id, DELETE /:id */ +export function createChannelsRoutes({ sessions, sseByChannel }: ChannelsDeps) { + const app = new Hono() + + /** GET / — list all channels (default first, then sub-channels) */ + app.get('/', async (c) => { + const subChannels = await readWebSubchannels() + const channels = [ + { id: 'default', label: 'Alice' }, + ...subChannels, + ] + return c.json({ channels }) + }) + + /** POST / — create a new sub-channel */ + app.post('/', async (c) => { + const body = await c.req.json() as { + id?: string + label?: string + systemPrompt?: string + provider?: string + vercelAiSdk?: { provider: string; model: string; baseUrl?: string; apiKey?: string } + agentSdk?: { model?: string; apiKey?: string; baseUrl?: string } + disabledTools?: string[] + } + + if (!body.id || !/^[a-z0-9-_]+$/.test(body.id)) { + return c.json({ error: 'id must be lowercase alphanumeric with hyphens/underscores' }, 400) + } + if (body.id === 'default') { + return c.json({ error: 'cannot use reserved id "default"' }, 400) + } + if (!body.label?.trim()) { + return c.json({ error: 'label is required' }, 400) + } + + const existing = await readWebSubchannels() + if (existing.find((ch) => ch.id === body.id)) { + return c.json({ error: 'channel id already exists' }, 409) + } + + const newChannel: WebChannel = { + id: body.id, + label: body.label.trim(), + ...(body.systemPrompt ? { systemPrompt: body.systemPrompt } : {}), + ...(body.provider === 'claude-code' || body.provider === 'vercel-ai-sdk' || body.provider === 'agent-sdk' + ? { provider: body.provider } + : {}), + ...(body.vercelAiSdk?.provider && body.vercelAiSdk?.model + ? { vercelAiSdk: body.vercelAiSdk } + : {}), + ...(body.agentSdk ? { agentSdk: body.agentSdk } : {}), + ...(body.disabledTools?.length ? { disabledTools: body.disabledTools } : {}), + } + + await writeWebSubchannels([...existing, newChannel]) + + // Initialize session and SSE map for the new channel + const session = new SessionStore(`web/${body.id}`) + await session.restore() + sessions.set(body.id, session) + sseByChannel.set(body.id, new Map()) + + return c.json({ channel: newChannel }, 201) + }) + + /** PUT /:id — update a sub-channel */ + app.put('/:id', async (c) => { + const id = c.req.param('id') + if (id === 'default') return c.json({ error: 'cannot modify default channel' }, 400) + + const body = await c.req.json() as { + label?: string + systemPrompt?: string + provider?: string + vercelAiSdk?: { provider: string; model: string; baseUrl?: string; apiKey?: string } | null + agentSdk?: { model?: string; apiKey?: string; baseUrl?: string } | null + disabledTools?: string[] + } + + const existing = await readWebSubchannels() + const idx = existing.findIndex((ch) => ch.id === id) + if (idx === -1) return c.json({ error: 'channel not found' }, 404) + + const updated: WebChannel = { + ...existing[idx], + ...(body.label !== undefined ? { label: body.label } : {}), + ...(body.systemPrompt !== undefined ? { systemPrompt: body.systemPrompt || undefined } : {}), + ...(body.provider === 'claude-code' || body.provider === 'vercel-ai-sdk' || body.provider === 'agent-sdk' + ? { provider: body.provider } + : body.provider === null || body.provider === '' + ? { provider: undefined } + : {}), + ...(body.vercelAiSdk !== undefined + ? { vercelAiSdk: body.vercelAiSdk?.provider && body.vercelAiSdk?.model ? body.vercelAiSdk : undefined } + : {}), + ...(body.agentSdk !== undefined + ? { agentSdk: body.agentSdk ?? undefined } + : {}), + ...(body.disabledTools !== undefined ? { disabledTools: body.disabledTools?.length ? body.disabledTools : undefined } : {}), + } + existing[idx] = updated + await writeWebSubchannels(existing) + + return c.json({ channel: updated }) + }) + + /** DELETE /:id — delete a sub-channel */ + app.delete('/:id', async (c) => { + const id = c.req.param('id') + if (id === 'default') return c.json({ error: 'cannot delete default channel' }, 400) + + const existing = await readWebSubchannels() + if (!existing.find((ch) => ch.id === id)) return c.json({ error: 'channel not found' }, 404) + + await writeWebSubchannels(existing.filter((ch) => ch.id !== id)) + + // Clean up in-memory state + sessions.delete(id) + sseByChannel.delete(id) + + return c.json({ success: true }) + }) + + return app +} diff --git a/src/connectors/web/routes/chat.ts b/src/connectors/web/routes/chat.ts index ba8db2f4..108c765c 100644 --- a/src/connectors/web/routes/chat.ts +++ b/src/connectors/web/routes/chat.ts @@ -4,7 +4,9 @@ import { readFile } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import { extname, join } from 'node:path' import type { EngineContext } from '../../../core/types.js' +import type { AskOptions } from '../../../core/ai-provider.js' import { SessionStore, toChatHistory } from '../../../core/session.js' +import { readWebSubchannels } from '../../../core/config.js' import { persistMedia, resolveMediaPath } from '../../../core/media-store.js' export interface SSEClient { @@ -14,29 +16,60 @@ export interface SSEClient { interface ChatDeps { ctx: EngineContext - session: SessionStore - sseClients: Map + sessions: Map + sseByChannel: Map> } /** Chat routes: POST /, GET /history, GET /events (SSE) */ -export function createChatRoutes({ ctx, session, sseClients }: ChatDeps) { +export function createChatRoutes({ ctx, sessions, sseByChannel }: ChatDeps) { const app = new Hono() app.post('/', async (c) => { - const body = await c.req.json<{ message?: string }>() + const body = await c.req.json() as { message?: string; channelId?: string } const message = body.message?.trim() if (!message) return c.json({ error: 'message is required' }, 400) - const receivedEntry = await ctx.eventLog.append('message.received', { - channel: 'web', to: 'default', prompt: message, - }) + const channelId = body.channelId ?? 'default' + const session = sessions.get(channelId) + if (!session) return c.json({ error: 'channel not found' }, 404) - const result = await ctx.engine.askWithSession(message, session, { + // Build AskOptions from channel config (if not default) + const opts: AskOptions = { historyPreamble: 'The following is the recent conversation from the Web UI. Use it as context if the user references earlier messages.', + } + if (channelId !== 'default') { + const channels = await readWebSubchannels() + const channel = channels.find((ch) => ch.id === channelId) + if (channel) { + if (channel.systemPrompt) opts.systemPrompt = channel.systemPrompt + if (channel.disabledTools?.length) opts.disabledTools = channel.disabledTools + if (channel.provider) opts.provider = channel.provider + if (channel.vercelAiSdk) opts.vercelAiSdk = channel.vercelAiSdk + if (channel.agentSdk) opts.agentSdk = channel.agentSdk + } + } + + const receivedEntry = await ctx.eventLog.append('message.received', { + channel: 'web', to: channelId, prompt: message, }) + const stream = ctx.agentCenter.askWithSession(message, session, opts) + + // Stream events to SSE clients for this channel as they arrive + const channelClients = sseByChannel.get(channelId) ?? new Map() + for await (const event of stream) { + if (event.type === 'done') continue + const data = JSON.stringify({ type: 'stream', event }) + for (const client of channelClients.values()) { + try { client.send(data) } catch { /* disconnected */ } + } + } + + // Stream fully drained — await resolves immediately with cached result + const result = await stream + await ctx.eventLog.append('message.sent', { - channel: 'web', to: 'default', prompt: message, + channel: 'web', to: channelId, prompt: message, reply: result.text, durationMs: Date.now() - receivedEntry.ts, }) @@ -52,14 +85,22 @@ export function createChatRoutes({ ctx, session, sseClients }: ChatDeps) { app.get('/history', async (c) => { const limit = Number(c.req.query('limit')) || 100 + const channelId = c.req.query('channel') ?? 'default' + const session = sessions.get(channelId) + if (!session) return c.json({ error: 'channel not found' }, 404) const entries = await session.readActive() return c.json({ messages: toChatHistory(entries).slice(-limit) }) }) app.get('/events', (c) => { + const channelId = c.req.query('channel') ?? 'default' + // Create SSE client map for this channel if it doesn't exist yet + if (!sseByChannel.has(channelId)) sseByChannel.set(channelId, new Map()) + const channelClients = sseByChannel.get(channelId)! + return streamSSE(c, async (stream) => { const clientId = randomUUID() - sseClients.set(clientId, { + channelClients.set(clientId, { id: clientId, send: (data) => { stream.writeSSE({ data }).catch(() => {}) }, }) @@ -70,7 +111,7 @@ export function createChatRoutes({ ctx, session, sseClients }: ChatDeps) { stream.onAbort(() => { clearInterval(pingInterval) - sseClients.delete(clientId) + channelClients.delete(clientId) }) await new Promise(() => {}) diff --git a/src/connectors/web/routes/config.ts b/src/connectors/web/routes/config.ts index 5ded535c..83688aee 100644 --- a/src/connectors/web/routes/config.ts +++ b/src/connectors/web/routes/config.ts @@ -23,8 +23,8 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) { try { const body = await c.req.json<{ backend?: string }>() const backend = body.backend - if (backend !== 'claude-code' && backend !== 'vercel-ai-sdk') { - return c.json({ error: 'Invalid backend. Must be "claude-code" or "vercel-ai-sdk".' }, 400) + if (backend !== 'claude-code' && backend !== 'vercel-ai-sdk' && backend !== 'agent-sdk') { + return c.json({ error: 'Invalid backend. Must be "claude-code", "vercel-ai-sdk", or "agent-sdk".' }, 400) } await writeAIConfig(backend as AIBackend) return c.json({ backend }) diff --git a/src/connectors/web/web-plugin.ts b/src/connectors/web/web-plugin.ts index 0b8a6844..4321f233 100644 --- a/src/connectors/web/web-plugin.ts +++ b/src/connectors/web/web-plugin.ts @@ -1,13 +1,16 @@ -import { Hono } from 'hono' +import { Hono, type Context } from 'hono' import { cors } from 'hono/cors' import { serve } from '@hono/node-server' import { serveStatic } from '@hono/node-server/serve-static' import { resolve } from 'node:path' import type { Plugin, EngineContext } from '../../core/types.js' import { SessionStore, type ContentBlock } from '../../core/session.js' -import type { ConnectorCenter, Connector } from '../../core/connector-center.js' +import type { Connector, SendPayload } from '../../core/connector-center.js' +import type { StreamableResult } from '../../core/ai-provider.js' import { persistMedia } from '../../core/media-store.js' +import { readWebSubchannels } from '../../core/config.js' import { createChatRoutes, createMediaRoutes, type SSEClient } from './routes/chat.js' +import { createChannelsRoutes } from './routes/channels.js' import { createConfigRoutes, createOpenbbRoutes } from './routes/config.js' import { createEventsRoutes } from './routes/events.js' import { createCronRoutes } from './routes/cron.js' @@ -24,19 +27,38 @@ export interface WebConfig { export class WebPlugin implements Plugin { name = 'web' private server: ReturnType | null = null - private sseClients = new Map() + /** SSE clients grouped by channel ID. Default channel: 'default'. */ + private sseByChannel = new Map>() private unregisterConnector?: () => void constructor(private config: WebConfig) {} async start(ctx: EngineContext) { - // Initialize session (mirrors Telegram's per-user pattern, single user for web) - const session = new SessionStore('web/default') - await session.restore() + // Load sub-channel definitions + const subChannels = await readWebSubchannels() + + // Initialize sessions for the default channel and all sub-channels + const sessions = new Map() + + const defaultSession = new SessionStore('web/default') + await defaultSession.restore() + sessions.set('default', defaultSession) + + for (const ch of subChannels) { + const session = new SessionStore(`web/${ch.id}`) + await session.restore() + sessions.set(ch.id, session) + } + + // Initialize SSE map for known channels (entries are created lazily too) + this.sseByChannel.set('default', new Map()) + for (const ch of subChannels) { + this.sseByChannel.set(ch.id, new Map()) + } const app = new Hono() - app.onError((err, c) => { + app.onError((err: Error, c: Context) => { if (err instanceof SyntaxError) { return c.json({ error: 'Invalid JSON' }, 400) } @@ -47,7 +69,8 @@ export class WebPlugin implements Plugin { app.use('/api/*', cors()) // ==================== Mount route modules ==================== - app.route('/api/chat', createChatRoutes({ ctx, session, sseClients: this.sseClients })) + app.route('/api/chat', createChatRoutes({ ctx, sessions, sseByChannel: this.sseByChannel })) + app.route('/api/channels', createChannelsRoutes({ sessions, sseByChannel: this.sseByChannel })) app.route('/api/media', createMediaRoutes()) app.route('/api/config', createConfigRoutes({ onConnectorsChange: async () => { await ctx.reconnectConnectors() }, @@ -67,8 +90,9 @@ export class WebPlugin implements Plugin { app.get('*', serveStatic({ root: uiRoot, path: 'index.html' })) // ==================== Connector registration ==================== + // The web connector only targets the main 'default' channel (heartbeat/cron notifications). this.unregisterConnector = ctx.connectorCenter.register( - this.createConnector(this.sseClients, session), + this.createConnector(this.sseByChannel, defaultSession), ) // ==================== Start server ==================== @@ -78,13 +102,13 @@ export class WebPlugin implements Plugin { } async stop() { - this.sseClients.clear() + this.sseByChannel.clear() this.unregisterConnector?.() this.server?.close() } private createConnector( - sseClients: Map, + sseByChannel: Map>, session: SessionStore, ): Connector { return { @@ -107,7 +131,9 @@ export class WebPlugin implements Plugin { source: payload.source, }) - for (const client of sseClients.values()) { + // Only broadcast to default channel SSE clients (heartbeat/cron stay in main channel) + const defaultClients = sseByChannel.get('default') ?? new Map() + for (const client of defaultClients.values()) { try { client.send(data) } catch { /* client disconnected */ } } @@ -116,12 +142,59 @@ export class WebPlugin implements Plugin { { type: 'text', text: payload.text }, ...media.map((m) => ({ type: 'image' as const, url: m.url })), ] - await session.appendAssistant(blocks, 'engine', { + await session.appendAssistant(blocks, 'vercel-ai', { kind: payload.kind, source: payload.source, }) - return { delivered: sseClients.size > 0 } + return { delivered: defaultClients.size > 0 } + }, + + sendStream: async (stream: StreamableResult, meta?: Pick) => { + const defaultClients = sseByChannel.get('default') ?? new Map() + + // Push streaming events to SSE clients as they arrive + for await (const event of stream) { + if (event.type === 'done') continue + const data = JSON.stringify({ type: 'stream', event }) + for (const client of defaultClients.values()) { + try { client.send(data) } catch { /* disconnected */ } + } + } + + // Get completed result (resolves immediately — drain already finished) + const result = await stream + + // Persist media + const media: Array<{ type: 'image'; url: string }> = [] + for (const m of result.media) { + const name = await persistMedia(m.path) + media.push({ type: 'image', url: `/api/media/${name}` }) + } + + // Push final message to SSE (same format as send()) + const data = JSON.stringify({ + type: 'message', + kind: meta?.kind ?? 'notification', + text: result.text, + media: media.length > 0 ? media : undefined, + source: meta?.source, + }) + for (const client of defaultClients.values()) { + try { client.send(data) } catch { /* disconnected */ } + } + + // Persist to session (push notifications appear in web chat history) + const blocks: ContentBlock[] = [ + { type: 'text', text: result.text }, + ...media.map((m) => ({ type: 'image' as const, url: m.url })), + ] + await session.appendAssistant(blocks, 'vercel-ai', { + kind: meta?.kind ?? 'notification', + source: meta?.source, + }) + + return { delivered: defaultClients.size > 0 } }, } } diff --git a/src/core/agent-center.ts b/src/core/agent-center.ts index 8e9c217e..a7461caa 100644 --- a/src/core/agent-center.ts +++ b/src/core/agent-center.ts @@ -1,26 +1,209 @@ /** - * AgentCenter — centralized AI agent management. + * AgentCenter — centralized AI agent orchestration. * - * Owns the ProviderRouter and exposes `ask()` / `askWithSession()` that - * always go through the provider route. Engine delegates to AgentCenter - * so that both stateless and session-aware calls are provider-routable. + * Owns the GenerateRouter and manages the full session pipeline: + * appendUser → compact → build input → call provider.generate() → pipeline → persist → done * - * Future: subagent orchestration will be managed here. + * Providers are slim data-source adapters; all shared logic lives here: + * - Session management (append, compact, read active) + * - Input format dispatch (text vs messages based on provider.inputKind) + * - Unified pipeline (logToolCall, stripImageData, extractMedia) + * - Message persistence (intermediate tool messages + final response) */ -import type { AIProvider, AskOptions, ProviderResult } from './ai-provider.js' -import type { SessionStore } from './session.js' +import type { AskOptions, ProviderResult, ProviderEvent, GenerateOpts } from './ai-provider.js' +import { GenerateRouter, StreamableResult } from './ai-provider.js' +import type { SessionStore, ContentBlock } from './session.js' +import { toTextHistory, toModelMessages } from './session.js' +import type { CompactionConfig } from './compaction.js' +import { compactIfNeeded } from './compaction.js' +import type { MediaAttachment } from './types.js' +import { extractMediaFromToolResultContent } from './media.js' +import { logToolCall } from '../ai-providers/log-tool-call.js' +import { stripImageData, buildChatHistoryPrompt, DEFAULT_MAX_HISTORY } from './provider-utils.js' + +// ==================== Types ==================== + +export interface AgentCenterOpts { + router: GenerateRouter + compaction: CompactionConfig + /** Default history preamble for text-based providers. */ + historyPreamble?: string + /** Default max history entries for text-based providers. */ + maxHistoryEntries?: number +} + +/** Tag used when persisting messages to session. */ +type ProviderTag = 'vercel-ai' | 'claude-code' | 'agent-sdk' + +// ==================== AgentCenter ==================== export class AgentCenter { - constructor(private provider: AIProvider) {} + private router: GenerateRouter + private compaction: CompactionConfig + private defaultPreamble?: string + private defaultMaxHistory: number + + constructor(opts: AgentCenterOpts) { + this.router = opts.router + this.compaction = opts.compaction + this.defaultPreamble = opts.historyPreamble + this.defaultMaxHistory = opts.maxHistoryEntries ?? DEFAULT_MAX_HISTORY + } /** Stateless prompt — routed through the configured AI provider. */ async ask(prompt: string): Promise { - return this.provider.ask(prompt) + return this.router.ask(prompt) + } + + /** Prompt with session history — full orchestration pipeline. */ + askWithSession(prompt: string, session: SessionStore, opts?: AskOptions): StreamableResult { + return new StreamableResult(this._generate(prompt, session, opts)) + } + + // ==================== Pipeline ==================== + + private async *_generate( + prompt: string, + session: SessionStore, + opts?: AskOptions, + ): AsyncGenerator { + const maxHistory = opts?.maxHistoryEntries ?? this.defaultMaxHistory + const preamble = opts?.historyPreamble ?? this.defaultPreamble + + // 1. Append user message to session + await session.appendUser(prompt, 'human') + + // 2. Resolve provider (may be overridden per-request) + const provider = await this.router.resolve(opts?.provider) + + // 3. Compact if needed (provider can override with custom strategy) + const compactionResult = provider.compact + ? await provider.compact(session, this.compaction) + : await compactIfNeeded( + session, + this.compaction, + async (summarizePrompt) => (await provider.ask(summarizePrompt)).text, + ) + + // 4. Read active window + const entries = compactionResult.activeEntries ?? await session.readActive() + + // 5. Build input based on provider.inputKind + const genOpts: GenerateOpts = { + disabledTools: opts?.disabledTools, + vercelAiSdk: opts?.vercelAiSdk, + agentSdk: opts?.agentSdk, + } + + let source: AsyncIterable + if (provider.inputKind === 'text') { + const textHistory = toTextHistory(entries).slice(-maxHistory) + const fullPrompt = buildChatHistoryPrompt(prompt, textHistory, preamble) + source = provider.generate( + { kind: 'text', prompt: fullPrompt, systemPrompt: opts?.systemPrompt }, + genOpts, + ) + } else { + const messages = toModelMessages(entries) + source = provider.generate( + { kind: 'messages', messages, systemPrompt: opts?.systemPrompt }, + genOpts, + ) + } + + // 6. Consume provider events — unified pipeline + const media: MediaAttachment[] = [] + const intermediateMessages: Array<{ role: 'assistant' | 'user'; content: ContentBlock[] }> = [] + let currentAssistantBlocks: ContentBlock[] = [] + let currentUserBlocks: ContentBlock[] = [] + let finalResult: ProviderResult | null = null + + for await (const event of source) { + switch (event.type) { + case 'tool_use': + // Unified logging — all providers get this now + logToolCall(event.name, event.input) + currentAssistantBlocks.push({ + type: 'tool_use', + id: event.id, + name: event.name, + input: event.input, + }) + yield event + break + + case 'tool_result': { + // Unified media extraction + image stripping + media.push(...extractMediaFromToolResultContent(event.content)) + const sessionContent = stripImageData(event.content) + currentUserBlocks.push({ + type: 'tool_result', + tool_use_id: event.tool_use_id, + content: sessionContent, + }) + + // Flush assistant blocks before user blocks (tool_use → tool_result) + if (currentAssistantBlocks.length > 0) { + intermediateMessages.push({ role: 'assistant', content: currentAssistantBlocks }) + currentAssistantBlocks = [] + } + if (currentUserBlocks.length > 0) { + intermediateMessages.push({ role: 'user', content: currentUserBlocks }) + currentUserBlocks = [] + } + yield event + break + } + + case 'text': + yield event + break + + case 'done': + finalResult = event.result + break + } + } + + // Flush any remaining intermediate blocks + if (currentAssistantBlocks.length > 0) { + intermediateMessages.push({ role: 'assistant', content: currentAssistantBlocks }) + } + if (currentUserBlocks.length > 0) { + intermediateMessages.push({ role: 'user', content: currentUserBlocks }) + } + + // 7. Persist intermediate messages to session + const providerTag = this.resolveProviderTag(provider) + for (const msg of intermediateMessages) { + if (msg.role === 'assistant') { + await session.appendAssistant(msg.content, providerTag) + } else { + await session.appendUser(msg.content, providerTag) + } + } + + // 8. Persist final response text + if (!finalResult) throw new Error('AgentCenter: provider stream ended without done event') + await session.appendAssistant(finalResult.text, providerTag) + + // 9. Yield done with merged media + yield { + type: 'done', + result: { + text: finalResult.text, + media: [...finalResult.media, ...media], + }, + } } - /** Prompt with session history — routed through the configured AI provider. */ - async askWithSession(prompt: string, session: SessionStore, opts?: AskOptions): Promise { - return this.provider.askWithSession(prompt, session, opts) + /** Determine the session provider tag based on the provider's inputKind. */ + private resolveProviderTag(provider: { inputKind: string }): ProviderTag { + // GenerateRouter stores the original provider references, so we can't + // introspect the class name. Use inputKind as a heuristic — text-based + // providers are Claude Code or Agent SDK, messages-based is Vercel. + // This is good enough; the tag is purely for session log provenance. + return provider.inputKind === 'messages' ? 'vercel-ai' : 'claude-code' } } diff --git a/src/core/ai-config.ts b/src/core/ai-config.ts index f7cbcd06..d142b15e 100644 --- a/src/core/ai-config.ts +++ b/src/core/ai-config.ts @@ -2,7 +2,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises' import { resolve } from 'node:path' import { readAIProviderConfig } from './config.js' -export type AIBackend = 'claude-code' | 'vercel-ai-sdk' +export type AIBackend = 'claude-code' | 'vercel-ai-sdk' | 'agent-sdk' const CONFIG_PATH = resolve('data/config/ai-provider.json') diff --git a/src/core/ai-provider.ts b/src/core/ai-provider.ts index 756596a6..87f4499b 100644 --- a/src/core/ai-provider.ts +++ b/src/core/ai-provider.ts @@ -1,24 +1,143 @@ /** - * AIProvider — unified abstraction over AI backends. + * AI Provider abstraction — GenerateProvider + GenerateRouter. * - * Each provider (Vercel AI SDK, Claude Code CLI, …) implements this interface - * with its own session management flow. ProviderRouter reads the runtime - * config and delegates to the correct implementation. + * GenerateProvider is a slim data-source adapter: each backend (Vercel AI SDK, + * Claude Code CLI, Agent SDK) implements `ask()` and `generate()`. + * Session management lives in AgentCenter, not here. + * + * GenerateRouter reads runtime config and resolves to the correct provider. */ import type { SessionStore } from './session.js' +import type { SDKModelMessage } from './session.js' +import type { CompactionConfig, CompactionResult } from './compaction.js' import type { MediaAttachment } from './types.js' import { readAIProviderConfig } from './config.js' +// ==================== Provider Events ==================== + +/** Streaming event emitted by AI providers during generation. */ +export type ProviderEvent = + | { type: 'tool_use'; id: string; name: string; input: unknown } + | { type: 'tool_result'; tool_use_id: string; content: string } + | { type: 'text'; text: string } + | { type: 'done'; result: ProviderResult } + +// ==================== StreamableResult ==================== + +/** + * A result that is both PromiseLike (for backward-compatible `await`) + * and AsyncIterable (for real-time event streaming). + * + * Internally drains the source AsyncIterable in the background, buffering + * events. Multiple consumers can iterate independently (each gets its own cursor). + */ +export class StreamableResult implements PromiseLike, AsyncIterable { + private _events: ProviderEvent[] = [] + private _done = false + private _result: ProviderResult | null = null + private _error: Error | null = null + private _waiters: Array<() => void> = [] + private _promise: Promise + + constructor(source: AsyncIterable) { + this._promise = this._drain(source) + } + + private async _drain(source: AsyncIterable): Promise { + try { + for await (const event of source) { + this._events.push(event) + if (event.type === 'done') this._result = event.result + this._notify() + } + } catch (err) { + this._error = err instanceof Error ? err : new Error(String(err)) + this._notify() + throw this._error + } finally { + this._done = true + this._notify() + } + if (!this._result) throw new Error('StreamableResult: stream ended without done event') + return this._result + } + + private _notify(): void { + for (const w of this._waiters.splice(0)) w() + } + + then( + onfulfilled?: ((value: ProviderResult) => T1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => T2 | PromiseLike) | null, + ): Promise { + return this._promise.then(onfulfilled, onrejected) + } + + async *[Symbol.asyncIterator](): AsyncIterableIterator { + let cursor = 0 + while (true) { + while (cursor < this._events.length) { + yield this._events[cursor++] + } + if (this._done) return + if (this._error) throw this._error + await new Promise((resolve) => this._waiters.push(resolve)) + } + } +} + // ==================== Types ==================== export interface AskOptions { - /** Preamble text inside block (Claude Code only). */ + /** + * Preamble text describing the conversation context. + * Claude Code: injected inside the `` text block. + * Vercel AI SDK: not used (native ModelMessage[] carries the history directly). + */ historyPreamble?: string - /** System prompt override (Claude Code only). */ + /** + * System prompt override for this call. + * Claude Code: passed as `--system-prompt` to the CLI. + * Vercel AI SDK: replaces the agent's `instructions` for this call (triggers agent re-creation if changed). + */ systemPrompt?: string - /** Max text history entries in . Default: 50 (Claude Code only). */ + /** + * Max text history entries to include in context. + * Claude Code: limits entries in the `` block. Default: 50. + * Vercel AI SDK: not used (compaction via `compactIfNeeded` controls context size). + */ maxHistoryEntries?: number + /** + * Tool names to disable for this call, in addition to the global disabled list. + * Claude Code: merged into `disallowedTools` CLI option. + * Vercel AI SDK: filtered out from the tool map before the agent is created. + */ + disabledTools?: string[] + /** + * AI provider to use for this call, overriding the global ai-provider.json config. + * Falls back to global config if not specified. + */ + provider?: 'claude-code' | 'vercel-ai-sdk' | 'agent-sdk' + /** + * Vercel AI SDK model override — per-request provider/model/baseUrl/apiKey. + * Only used when the active backend is 'vercel-ai-sdk'. + */ + vercelAiSdk?: { + provider: string + model: string + baseUrl?: string + apiKey?: string + } + /** + * Agent SDK model override — per-request model/apiKey/baseUrl. + * Only used when the active backend is 'agent-sdk'. + */ + agentSdk?: { + model?: string + apiKey?: string + baseUrl?: string + } } export interface ProviderResult { @@ -26,36 +145,73 @@ export interface ProviderResult { media: MediaAttachment[] } -/** Unified AI provider — each backend implements its own session handling. */ -export interface AIProvider { - /** Stateless prompt — no session context. */ +// ==================== GenerateProvider ==================== + +/** + * Input prepared by AgentCenter, dispatched by provider.inputKind. + * + * - 'text': Claude Code / Agent SDK — single string prompt with baked in. + * - 'messages': Vercel AI SDK — structured ModelMessage[] (history carried natively). + */ +export type GenerateInput = + | { kind: 'text'; prompt: string; systemPrompt?: string } + | { kind: 'messages'; messages: SDKModelMessage[]; systemPrompt?: string } + +/** Per-request options passed through to the underlying provider. */ +export interface GenerateOpts { + disabledTools?: string[] + vercelAiSdk?: { provider: string; model: string; baseUrl?: string; apiKey?: string } + agentSdk?: { model?: string; apiKey?: string; baseUrl?: string } +} + +/** + * Slim provider interface — pure data-source adapter. + * + * Does NOT touch session management. AgentCenter prepares the input, + * the provider calls the backend and yields ProviderEvents. + */ +export interface GenerateProvider { + /** Which input format this provider expects. */ + readonly inputKind: 'text' | 'messages' + /** Stateless one-shot prompt (used for compaction summarization, etc.). */ ask(prompt: string): Promise - /** Prompt with session history and compaction. */ - askWithSession(prompt: string, session: SessionStore, opts?: AskOptions): Promise + /** Stream events from the backend. Yields tool_use/tool_result/text, then done. */ + generate(input: GenerateInput, opts?: GenerateOpts): AsyncIterable + /** + * Optional: custom compaction strategy. If implemented, AgentCenter delegates + * compaction to the provider instead of using the default compactIfNeeded. + * + * Use case: providers with native server-side compaction (e.g. Anthropic API + * compact-2026-01-12) can bypass the local JSONL-based summarization. + */ + compact?(session: SessionStore, config: CompactionConfig): Promise } -// ==================== Router ==================== +// ==================== GenerateRouter ==================== -/** Reads runtime AI config and delegates to the correct provider. */ -export class ProviderRouter implements AIProvider { +/** Reads runtime AI config and resolves to the correct GenerateProvider. */ +export class GenerateRouter { constructor( - private vercel: AIProvider, - private claudeCode: AIProvider | null, + private vercel: GenerateProvider, + private claudeCode: GenerateProvider | null, + private agentSdk: GenerateProvider | null = null, ) {} - async ask(prompt: string): Promise { + /** Resolve the active provider, optionally overridden per-request. */ + async resolve(override?: string): Promise { + if (override === 'agent-sdk' && this.agentSdk) return this.agentSdk + if (override === 'claude-code' && this.claudeCode) return this.claudeCode + if (override === 'vercel-ai-sdk') return this.vercel + const config = await readAIProviderConfig() - if (config.backend === 'claude-code' && this.claudeCode) { - return this.claudeCode.ask(prompt) - } - return this.vercel.ask(prompt) + if (config.backend === 'agent-sdk' && this.agentSdk) return this.agentSdk + if (config.backend === 'claude-code' && this.claudeCode) return this.claudeCode + return this.vercel } - async askWithSession(prompt: string, session: SessionStore, opts?: AskOptions): Promise { - const config = await readAIProviderConfig() - if (config.backend === 'claude-code' && this.claudeCode) { - return this.claudeCode.askWithSession(prompt, session, opts) - } - return this.vercel.askWithSession(prompt, session, opts) + /** Stateless ask — delegates to the resolved provider. */ + async ask(prompt: string): Promise { + const provider = await this.resolve() + return provider.ask(prompt) } } diff --git a/src/core/async-channel.ts b/src/core/async-channel.ts new file mode 100644 index 00000000..c3f799bf --- /dev/null +++ b/src/core/async-channel.ts @@ -0,0 +1,84 @@ +/** + * AsyncChannel — push-to-pull bridge for converting callbacks into AsyncIterable. + * + * Used by Claude Code and Vercel providers where events arrive via synchronous + * callbacks (onToolUse, onToolResult, onStepFinish) but consumers need an + * AsyncIterator interface. + */ + +export interface AsyncChannel { + push(value: T): void + close(): void + error(err: Error): void + [Symbol.asyncIterator](): AsyncIterableIterator +} + +export function createChannel(): AsyncChannel { + const queue: T[] = [] + let done = false + let err: Error | null = null + let waiter: ((value: IteratorResult) => void) | null = null + + return { + push(value: T) { + if (done) return + if (waiter) { + const w = waiter + waiter = null + w({ value, done: false }) + } else { + queue.push(value) + } + }, + + close() { + if (done) return + done = true + if (waiter) { + const w = waiter + waiter = null + w({ value: undefined as unknown as T, done: true }) + } + }, + + error(e: Error) { + if (done) return + done = true + err = e + if (waiter) { + const w = waiter + waiter = null + // Signal error by rejecting — but IteratorResult doesn't support rejection. + // Instead, store error and let next() throw on subsequent call. + w({ value: undefined as unknown as T, done: true }) + } + }, + + [Symbol.asyncIterator](): AsyncIterableIterator { + return { + next(): Promise> { + if (queue.length > 0) { + return Promise.resolve({ value: queue.shift()!, done: false }) + } + if (err) { + return Promise.reject(err) + } + if (done) { + return Promise.resolve({ value: undefined as unknown as T, done: true }) + } + return new Promise((resolve, reject) => { + waiter = (result) => { + // Check if error was set between push and resolve + if (err) return reject(err) + resolve(result) + } + }) + }, + + [Symbol.asyncIterator]() { + return this + }, + } + }, + } +} diff --git a/src/core/config.ts b/src/core/config.ts index 0ef8adc5..7fa71fbe 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -14,7 +14,7 @@ const engineSchema = z.object({ }) export const aiProviderSchema = z.object({ - backend: z.enum(['claude-code', 'vercel-ai-sdk']).default('claude-code'), + backend: z.enum(['claude-code', 'vercel-ai-sdk', 'agent-sdk']).default('claude-code'), provider: z.string().default('anthropic'), model: z.string().default('claude-sonnet-4-6'), baseUrl: z.string().min(1).optional(), @@ -122,6 +122,11 @@ const openbbSchema = z.object({ tiingo: z.string().optional(), biztoc: z.string().optional(), }).default({}), + dataBackend: z.enum(['sdk', 'openbb']).default('sdk'), + apiServer: z.object({ + enabled: z.boolean().default(false), + port: z.number().int().min(1024).max(65535).default(6901), + }).default({ enabled: false, port: 6901 }), }) const compactionSchema = z.object({ @@ -167,6 +172,41 @@ export const toolsSchema = z.object({ disabled: z.array(z.string()).default([]), }) +/** Vercel AI SDK model override — per-channel provider/model/key/endpoint. */ +export const vercelAiSdkOverrideSchema = z.object({ + provider: z.string(), + model: z.string(), + baseUrl: z.string().optional(), + apiKey: z.string().optional(), +}) + +/** Agent SDK model override — per-channel model/key/endpoint. */ +export const agentSdkOverrideSchema = z.object({ + model: z.string().optional(), + apiKey: z.string().optional(), + baseUrl: z.string().optional(), +}) + +export const webSubchannelSchema = z.object({ + /** URL-safe identifier. Used as session path segment: data/sessions/web/{id}.jsonl */ + id: z.string().regex(/^[a-z0-9-_]+$/, 'id must be lowercase alphanumeric with hyphens/underscores'), + label: z.string().min(1), + /** System prompt override for this channel. */ + systemPrompt: z.string().optional(), + /** AI backend override. Falls back to global config if omitted. */ + provider: z.enum(['claude-code', 'vercel-ai-sdk', 'agent-sdk']).optional(), + /** Vercel AI SDK model override. Only used when provider is 'vercel-ai-sdk'. */ + vercelAiSdk: vercelAiSdkOverrideSchema.optional(), + /** Agent SDK model override. Only used when provider is 'agent-sdk'. */ + agentSdk: agentSdkOverrideSchema.optional(), + /** Tool names to disable in addition to the global disabled list. */ + disabledTools: z.array(z.string()).optional(), +}) + +export const webSubchannelsSchema = z.array(webSubchannelSchema) + +export type WebChannel = z.infer + // ==================== Platform + Account Config ==================== const guardConfigSchema = z.object({ @@ -521,3 +561,16 @@ export async function writeConfigSection(section: ConfigSection, data: unknown): await writeFile(resolve(CONFIG_DIR, sectionFiles[section]), JSON.stringify(validated, null, 2) + '\n') return validated } + +/** Read web sub-channel definitions from disk. Returns empty array if file missing. */ +export async function readWebSubchannels(): Promise { + const raw = await loadJsonFile('web-subchannels.json') + return webSubchannelsSchema.parse(raw ?? []) +} + +/** Write web sub-channel definitions to disk. */ +export async function writeWebSubchannels(channels: WebChannel[]): Promise { + const validated = webSubchannelsSchema.parse(channels) + await mkdir(CONFIG_DIR, { recursive: true }) + await writeFile(resolve(CONFIG_DIR, 'web-subchannels.json'), JSON.stringify(validated, null, 2) + '\n') +} diff --git a/src/core/connector-center.ts b/src/core/connector-center.ts index 5d61a162..0df07f37 100644 --- a/src/core/connector-center.ts +++ b/src/core/connector-center.ts @@ -12,6 +12,7 @@ import type { MediaAttachment } from './types.js' import type { EventLog } from './event-log.js' +import type { StreamableResult } from './ai-provider.js' // ==================== Send Types ==================== @@ -58,6 +59,15 @@ export interface Connector { readonly capabilities: ConnectorCapabilities /** Send a structured payload through this connector. */ send(payload: SendPayload): Promise + /** + * Optional: stream AI response events to the client in real-time. + * Connectors that support this can push ProviderEvents (tool_use, tool_result, text) + * as they arrive, then deliver the final result at the end. + * + * If not implemented, ConnectorCenter falls back to draining the stream + * and calling send() with the completed result. + */ + sendStream?(stream: StreamableResult, meta?: Pick): Promise } // ==================== Notify Types ==================== @@ -140,6 +150,37 @@ export class ConnectorCenter { return { ...result, channel: target.channel } } + /** + * Stream a notification to the last-interacted connector. + * If the connector supports sendStream, delegates streaming directly. + * Otherwise drains the stream and falls back to send() with the completed result. + */ + async notifyStream(stream: StreamableResult, opts?: NotifyOpts): Promise { + const target = this.resolveTarget() + if (!target) { + await stream // drain to prevent hanging generator + return { delivered: false } + } + + if (target.sendStream) { + const result = await target.sendStream(stream, { + kind: opts?.kind ?? 'notification', + source: opts?.source, + }) + return { ...result, channel: target.channel } + } + + // Fallback: drain stream, send completed result + const completed = await stream + const payload = this.buildPayload(completed.text, { + kind: opts?.kind, + media: completed.media, + source: opts?.source, + }) + const result = await target.send(payload) + return { ...result, channel: target.channel } + } + /** * Broadcast a notification to all push-capable connectors. * Returns one result per connector that was attempted. diff --git a/src/core/engine.spec.ts b/src/core/engine.spec.ts index 5fc20047..0d62a34b 100644 --- a/src/core/engine.spec.ts +++ b/src/core/engine.spec.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { LanguageModel, Tool } from 'ai' import { MockLanguageModelV3 } from 'ai/test' -import { Engine } from './engine.js' import { AgentCenter } from './agent-center.js' +import { GenerateRouter } from './ai-provider.js' import { DEFAULT_COMPACTION_CONFIG, type CompactionConfig } from './compaction.js' import { VercelAIProvider } from '../ai-providers/vercel-ai-sdk/vercel-provider.js' import { createModelFromConfig } from './model-factory.js' @@ -27,7 +27,7 @@ function makeMockModel(text = 'mock response') { return new MockLanguageModelV3({ doGenerate: makeDoGenerate(text) }) } -interface MakeEngineOpts { +interface MakeAgentCenterOpts { model?: LanguageModel tools?: Record instructions?: string @@ -35,7 +35,7 @@ interface MakeEngineOpts { compaction?: CompactionConfig } -function makeEngine(overrides: MakeEngineOpts = {}): Engine { +function makeAgentCenter(overrides: MakeAgentCenterOpts = {}): AgentCenter { const model = overrides.model ?? makeMockModel() const tools = overrides.tools ?? {} const instructions = overrides.instructions ?? 'You are a test agent.' @@ -43,10 +43,10 @@ function makeEngine(overrides: MakeEngineOpts = {}): Engine { const compaction = overrides.compaction ?? DEFAULT_COMPACTION_CONFIG vi.mocked(createModelFromConfig).mockResolvedValue({ model, key: 'test:mock-model' }) - const provider = new VercelAIProvider(() => tools, instructions, maxSteps, compaction) - const agentCenter = new AgentCenter(provider) + const provider = new VercelAIProvider(() => tools, instructions, maxSteps) + const router = new GenerateRouter(provider, null) - return new Engine({ agentCenter }) + return new AgentCenter({ router, compaction }) } /** In-memory SessionStore mock (no filesystem). */ @@ -104,7 +104,7 @@ vi.mock('./compaction.js', async (importOriginal) => { // ==================== Tests ==================== -describe('Engine', () => { +describe('AgentCenter', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -112,9 +112,9 @@ describe('Engine', () => { // -------------------- Construction -------------------- describe('constructor', () => { - it('creates an engine with agentCenter', () => { - const engine = makeEngine({ instructions: 'custom instructions' }) - expect(engine).toBeInstanceOf(Engine) + it('creates an AgentCenter with router and compaction', () => { + const agentCenter = makeAgentCenter({ instructions: 'custom instructions' }) + expect(agentCenter).toBeInstanceOf(AgentCenter) }) }) @@ -123,9 +123,9 @@ describe('Engine', () => { describe('ask()', () => { it('returns text from the model', async () => { const model = makeMockModel('hello world') - const engine = makeEngine({ model }) + const agentCenter = makeAgentCenter({ model }) - const result = await engine.ask('say hello') + const result = await agentCenter.ask('say hello') expect(result.text).toBe('hello world') expect(result.media).toEqual([]) }) @@ -142,21 +142,17 @@ describe('Engine', () => { warnings: [], }, }) - const engine = makeEngine({ model }) + const agentCenter = makeAgentCenter({ model }) - const result = await engine.ask('empty response') + const result = await agentCenter.ask('empty response') expect(result.text).toBe('') }) - it('collects media from tool results via onStepFinish', async () => { - // Use a model that produces tool calls to test media extraction. - // Since MockLanguageModelV3 doesn't easily simulate multi-step tool calls, - // we'll test media extraction at the unit level separately. - // Here we verify the basic flow returns empty media when no tools produce media. + it('returns empty media when no tools produce media', async () => { const model = makeMockModel('no media') - const engine = makeEngine({ model }) + const agentCenter = makeAgentCenter({ model }) - const result = await engine.ask('test') + const result = await agentCenter.ask('test') expect(result.media).toEqual([]) }) }) @@ -166,30 +162,30 @@ describe('Engine', () => { describe('askWithSession()', () => { it('appends user message to session before generating', async () => { const model = makeMockModel('session response') - const engine = makeEngine({ model }) + const agentCenter = makeAgentCenter({ model }) const session = makeSessionMock() - await engine.askWithSession('user prompt', session) + await agentCenter.askWithSession('user prompt', session) expect(session.appendUser).toHaveBeenCalledWith('user prompt', 'human') }) it('appends assistant response to session after generating', async () => { const model = makeMockModel('assistant reply') - const engine = makeEngine({ model }) + const agentCenter = makeAgentCenter({ model }) const session = makeSessionMock() - await engine.askWithSession('hello', session) + await agentCenter.askWithSession('hello', session) - expect(session.appendAssistant).toHaveBeenCalledWith('assistant reply', 'engine') + expect(session.appendAssistant).toHaveBeenCalledWith('assistant reply', 'vercel-ai') }) it('returns the generated text and empty media', async () => { const model = makeMockModel('generated text') - const engine = makeEngine({ model }) + const agentCenter = makeAgentCenter({ model }) const session = makeSessionMock() - const result = await engine.askWithSession('prompt', session) + const result = await agentCenter.askWithSession('prompt', session) expect(result.text).toBe('generated text') expect(result.media).toEqual([]) }) @@ -203,10 +199,10 @@ describe('Engine', () => { autoCompactBuffer: 5_000, microcompactKeepRecent: 2, } - const engine = makeEngine({ model, compaction }) + const agentCenter = makeAgentCenter({ model, compaction }) const session = makeSessionMock() - await engine.askWithSession('test', session) + await agentCenter.askWithSession('test', session) expect(compactIfNeeded).toHaveBeenCalledWith( session, @@ -232,10 +228,10 @@ describe('Engine', () => { }) const model = makeMockModel('from compacted') - const engine = makeEngine({ model }) + const agentCenter = makeAgentCenter({ model }) const session = makeSessionMock() - const result = await engine.askWithSession('test', session) + const result = await agentCenter.askWithSession('test', session) expect(result.text).toBe('from compacted') // readActive should NOT be called when activeEntries is provided expect(session.readActive).not.toHaveBeenCalled() @@ -249,10 +245,10 @@ describe('Engine', () => { }) const model = makeMockModel('from readActive') - const engine = makeEngine({ model }) + const agentCenter = makeAgentCenter({ model }) const session = makeSessionMock() - await engine.askWithSession('test', session) + await agentCenter.askWithSession('test', session) expect(session.readActive).toHaveBeenCalled() }) }) @@ -264,9 +260,9 @@ describe('Engine', () => { const model = new MockLanguageModelV3({ doGenerate: async () => { throw new Error('boom') }, }) - const engine = makeEngine({ model }) + const agentCenter = makeAgentCenter({ model }) - await expect(engine.ask('fail')).rejects.toThrow('boom') + await expect(agentCenter.ask('fail')).rejects.toThrow('boom') }) }) }) diff --git a/src/core/engine.ts b/src/core/engine.ts deleted file mode 100644 index 3b20b8ee..00000000 --- a/src/core/engine.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Engine — AI conversation service. - * - * Thin facade that delegates all calls to AgentCenter, which routes - * through the configured AI provider (Vercel AI SDK, Claude Code, etc.) - * via ProviderRouter. - * - * Both `ask()` and `askWithSession()` go through the provider route. - * - * Concurrency control is NOT handled here — callers (Web, Telegram, Cron, etc.) - * manage their own serialization as appropriate for their context. - */ - -import type { MediaAttachment } from './types.js' -import type { SessionStore } from './session.js' -import type { AskOptions } from './ai-provider.js' -import type { AgentCenter } from './agent-center.js' - -// ==================== Types ==================== - -export interface EngineOpts { - /** The AgentCenter that owns provider routing. */ - agentCenter: AgentCenter -} - -export interface EngineResult { - text: string - /** Media produced by tools during the generation (e.g. screenshots). */ - media: MediaAttachment[] -} - -// ==================== Engine ==================== - -export class Engine { - private agentCenter: AgentCenter - - constructor(opts: EngineOpts) { - this.agentCenter = opts.agentCenter - } - - // ==================== Public API ==================== - - /** Simple prompt (no session context). Routed through the configured AI provider. */ - async ask(prompt: string): Promise { - return this.agentCenter.ask(prompt) - } - - /** Prompt with session — routed through the configured AI provider. */ - async askWithSession(prompt: string, session: SessionStore, opts?: AskOptions): Promise { - return this.agentCenter.askWithSession(prompt, session, opts) - } -} diff --git a/src/core/model-factory.ts b/src/core/model-factory.ts index af47cd6a..8326bf85 100644 --- a/src/core/model-factory.ts +++ b/src/core/model-factory.ts @@ -16,27 +16,45 @@ export interface ModelFromConfig { key: string } -export async function createModelFromConfig(): Promise { +/** Per-request model override (e.g. from a sub-channel's vercelAiSdk config). */ +export interface ModelOverride { + provider: string + model: string + baseUrl?: string + apiKey?: string +} + +export async function createModelFromConfig(override?: ModelOverride): Promise { + // Resolve effective values: override takes precedence over global config const config = await readAIProviderConfig() - const key = `${config.provider}:${config.model}:${config.baseUrl ?? ''}` + const p = override?.provider ?? config.provider + const m = override?.model ?? config.model + const url = override?.baseUrl ?? config.baseUrl + const key = `${p}:${m}:${url ?? ''}` + + // Resolve API key: override.apiKey > global config.apiKeys[provider] + const resolveApiKey = (provider: string) => { + if (override?.apiKey) return override.apiKey + return (config.apiKeys as Record)[provider] || undefined + } - switch (config.provider) { + switch (p) { case 'anthropic': { const { createAnthropic } = await import('@ai-sdk/anthropic') - const client = createAnthropic({ apiKey: config.apiKeys.anthropic || undefined, baseURL: config.baseUrl || undefined }) - return { model: client(config.model), key } + const client = createAnthropic({ apiKey: resolveApiKey('anthropic'), baseURL: url || undefined }) + return { model: client(m), key } } case 'openai': { const { createOpenAI } = await import('@ai-sdk/openai') - const client = createOpenAI({ apiKey: config.apiKeys.openai || undefined, baseURL: config.baseUrl || undefined }) - return { model: client(config.model), key } + const client = createOpenAI({ apiKey: resolveApiKey('openai'), baseURL: url || undefined }) + return { model: client(m), key } } case 'google': { const { createGoogleGenerativeAI } = await import('@ai-sdk/google') - const client = createGoogleGenerativeAI({ apiKey: config.apiKeys.google || undefined, baseURL: config.baseUrl || undefined }) - return { model: client(config.model), key } + const client = createGoogleGenerativeAI({ apiKey: resolveApiKey('google'), baseURL: url || undefined }) + return { model: client(m), key } } default: - throw new Error(`Unsupported model provider: "${config.provider}". Supported: anthropic, openai, google`) + throw new Error(`Unsupported model provider: "${p}". Supported: anthropic, openai, google`) } } diff --git a/src/core/provider-utils.ts b/src/core/provider-utils.ts new file mode 100644 index 00000000..b40c8aea --- /dev/null +++ b/src/core/provider-utils.ts @@ -0,0 +1,100 @@ +/** + * Shared utilities extracted from claude-code/provider.ts and agent-sdk/query.ts. + * + * These were previously copy-pasted across multiple providers. + * Now centralized here for single-source-of-truth usage. + */ + +// ==================== Strip Image Data ==================== + +/** Strip base64 image data from tool_result content before persisting to session. */ +export function stripImageData(raw: string): string { + try { + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return raw + let changed = false + const cleaned = parsed.map((item: Record) => { + if (item.type === 'image' && (item.source as Record)?.data) { + changed = true + return { type: 'text', text: '[Image saved to disk — use Read tool to view the file]' } + } + return item + }) + return changed ? JSON.stringify(cleaned) : raw + } catch { return raw } +} + +// ==================== Tool Permission Lists ==================== + +/** Tools pre-approved in normal mode (no Bash). */ +export const NORMAL_ALLOWED_TOOLS = [ + 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebSearch', 'WebFetch', + 'mcp__open-alice__*', +] + +/** Tools pre-approved in evolution mode (includes Bash). */ +export const EVOLUTION_ALLOWED_TOOLS = [ + 'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep', 'WebSearch', 'WebFetch', + 'mcp__open-alice__*', +] + +/** Extra tools to disallow in normal mode. */ +export const NORMAL_EXTRA_DISALLOWED = ['Bash'] + +/** Extra tools to disallow in evolution mode. */ +export const EVOLUTION_EXTRA_DISALLOWED: string[] = [] + +/** Resolve tool permissions based on evolution mode and explicit overrides. */ +export function resolveToolPermissions(opts: { + evolutionMode?: boolean + allowedTools?: string[] + disallowedTools?: string[] +}): { allowed: string[]; disallowed: string[] } { + const { evolutionMode = false, allowedTools = [], disallowedTools = [] } = opts + const modeAllowed = evolutionMode ? EVOLUTION_ALLOWED_TOOLS : NORMAL_ALLOWED_TOOLS + const modeDisallowed = evolutionMode ? EVOLUTION_EXTRA_DISALLOWED : NORMAL_EXTRA_DISALLOWED + return { + allowed: allowedTools.length > 0 ? allowedTools : modeAllowed, + disallowed: [...disallowedTools, ...modeDisallowed], + } +} + +// ==================== Chat History Prompt ==================== + +export interface TextHistoryEntry { + role: 'user' | 'assistant' + text: string +} + +const DEFAULT_PREAMBLE = + 'The following is the recent conversation history. Use it as context if it references earlier events or decisions.' + +/** + * Build a full prompt with `` block prepended. + * Used by text-based providers (Claude Code CLI, Agent SDK) that receive + * a single string prompt rather than structured ModelMessage[]. + */ +export function buildChatHistoryPrompt( + prompt: string, + textHistory: TextHistoryEntry[], + preamble?: string, +): string { + if (textHistory.length === 0) return prompt + + const lines = textHistory.map((entry) => { + const tag = entry.role === 'user' ? 'User' : 'Bot' + return `[${tag}] ${entry.text}` + }) + return [ + '', + preamble ?? DEFAULT_PREAMBLE, + '', + ...lines, + '', + '', + prompt, + ].join('\n') +} + +/** Default max history entries for text-based providers. */ +export const DEFAULT_MAX_HISTORY = 50 diff --git a/src/core/session.ts b/src/core/session.ts index 6bdea9ee..b9d46d20 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -36,7 +36,7 @@ export interface SessionEntry { sessionId: string timestamp: string /** Which provider generated this entry. */ - provider?: 'engine' | 'claude-code' | 'human' | 'compaction' + provider?: 'vercel-ai' | 'claude-code' | 'agent-sdk' | 'human' | 'compaction' cwd?: string /** Arbitrary metadata attached to the entry (e.g. { kind: 'notification', source: 'heartbeat' }). */ metadata?: Record @@ -87,7 +87,7 @@ export class SessionStore { /** Append an assistant message to the session. */ async appendAssistant( content: string | ContentBlock[], - provider: SessionEntry['provider'] = 'engine', + provider: SessionEntry['provider'] = 'vercel-ai', metadata?: Record, ): Promise { return this.append({ diff --git a/src/core/types.ts b/src/core/types.ts index 84b83569..d6e347a1 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -2,13 +2,13 @@ import type { AccountManager } from '../extension/trading/index.js' import type { ITradingGit } from '../extension/trading/git/interfaces.js' import type { CronEngine } from '../task/cron/engine.js' import type { Heartbeat } from '../task/heartbeat/index.js' -import type { Config } from './config.js' +import type { Config, WebChannel } from './config.js' import type { ConnectorCenter } from './connector-center.js' -import type { Engine } from './engine.js' +import type { AgentCenter } from './agent-center.js' import type { EventLog } from './event-log.js' import type { ToolCenter } from './tool-center.js' -export type { Config } +export type { Config, WebChannel } export interface Plugin { name: string @@ -25,7 +25,7 @@ export interface ReconnectResult { export interface EngineContext { config: Config connectorCenter: ConnectorCenter - engine: Engine + agentCenter: AgentCenter eventLog: EventLog heartbeat: Heartbeat cronEngine: CronEngine diff --git a/src/extension/analysis-kit/adapter.ts b/src/extension/analysis-kit/adapter.ts index 8f9e7b92..7030215d 100644 --- a/src/extension/analysis-kit/adapter.ts +++ b/src/extension/analysis-kit/adapter.ts @@ -8,9 +8,7 @@ import { tool } from 'ai' import { z } from 'zod' -import type { OpenBBEquityClient } from '@/openbb/equity/client' -import type { OpenBBCryptoClient } from '@/openbb/crypto/client' -import type { OpenBBCurrencyClient } from '@/openbb/currency/client' +import type { EquityClientLike, CryptoClientLike, CurrencyClientLike } from '@/openbb/sdk/types' import { IndicatorCalculator } from './indicator/calculator' import type { IndicatorContext, OhlcvData } from './indicator/types' @@ -40,9 +38,9 @@ function buildStartDate(interval: string): string { function buildContext( asset: 'equity' | 'crypto' | 'currency', - equityClient: OpenBBEquityClient, - cryptoClient: OpenBBCryptoClient, - currencyClient: OpenBBCurrencyClient, + equityClient: EquityClientLike, + cryptoClient: CryptoClientLike, + currencyClient: CurrencyClientLike, ): IndicatorContext { return { getHistoricalData: async (symbol, interval) => { @@ -68,9 +66,9 @@ function buildContext( } export function createAnalysisTools( - equityClient: OpenBBEquityClient, - cryptoClient: OpenBBCryptoClient, - currencyClient: OpenBBCurrencyClient, + equityClient: EquityClientLike, + cryptoClient: CryptoClientLike, + currencyClient: CurrencyClientLike, ) { return { calculateIndicator: tool({ diff --git a/src/extension/equity/adapter.ts b/src/extension/equity/adapter.ts index 5f24c57e..75f7a13c 100644 --- a/src/extension/equity/adapter.ts +++ b/src/extension/equity/adapter.ts @@ -8,9 +8,9 @@ import { tool } from 'ai' import { z } from 'zod' -import type { OpenBBEquityClient } from '@/openbb/equity/client' +import type { EquityClientLike } from '@/openbb/sdk/types' -export function createEquityTools(equityClient: OpenBBEquityClient) { +export function createEquityTools(equityClient: EquityClientLike) { return { equityGetProfile: tool({ description: `Get company profile and key valuation metrics for a stock. diff --git a/src/extension/market/adapter.ts b/src/extension/market/adapter.ts index 1b89bd88..857ba1eb 100644 --- a/src/extension/market/adapter.ts +++ b/src/extension/market/adapter.ts @@ -12,13 +12,12 @@ import { tool } from 'ai' import { z } from 'zod' import type { SymbolIndex } from '@/openbb/equity/SymbolIndex' -import type { OpenBBCryptoClient } from '@/openbb/crypto/client' -import type { OpenBBCurrencyClient } from '@/openbb/currency/client' +import type { CryptoClientLike, CurrencyClientLike } from '@/openbb/sdk/types' export function createMarketSearchTools( symbolIndex: SymbolIndex, - cryptoClient: OpenBBCryptoClient, - currencyClient: OpenBBCurrencyClient, + cryptoClient: CryptoClientLike, + currencyClient: CurrencyClientLike, ) { return { marketSearchForResearch: tool({ diff --git a/src/extension/news-collector/piggyback.ts b/src/extension/news-collector/piggyback.ts index f36955fa..43a7d020 100644 --- a/src/extension/news-collector/piggyback.ts +++ b/src/extension/news-collector/piggyback.ts @@ -1,7 +1,7 @@ /** * News Collector — OpenBB piggyback * - * Wraps the existing newsGetWorld / newsGetCompany tool execute functions + * Wraps the newsGetCompany tool execute function * to capture API results and ingest them into the persistent store. * Results are returned to the agent unchanged; ingestion is fire-and-forget. */ @@ -19,12 +19,6 @@ export function wrapNewsToolsForPiggyback>( ): T { const wrapped = { ...originalTools } - if (originalTools.newsGetWorld) { - ;(wrapped as Record).newsGetWorld = wrapTool(originalTools.newsGetWorld, (result) => { - ingestOpenBBResults(store, result, 'openbb-world').catch(() => {}) - }) - } - if (originalTools.newsGetCompany) { ;(wrapped as Record).newsGetCompany = wrapTool(originalTools.newsGetCompany, (result, args) => { const symbol = args && typeof args === 'object' && 'symbol' in args ? String(args.symbol) : undefined diff --git a/src/extension/news/adapter.ts b/src/extension/news/adapter.ts index f8eec646..8d393468 100644 --- a/src/extension/news/adapter.ts +++ b/src/extension/news/adapter.ts @@ -1,34 +1,18 @@ /** * News AI Tools * - * newsGetWorld: 全球新闻,用于宏观面判断。 * newsGetCompany: 个股新闻,用于事件驱动和异动归因。 */ import { tool } from 'ai' import { z } from 'zod' -import type { OpenBBNewsClient } from '@/openbb/news/client' +import type { NewsClientLike } from '@/openbb/sdk/types' export function createNewsTools( - newsClient: OpenBBNewsClient, - providers: { companyProvider: string; worldProvider: string }, + newsClient: NewsClientLike, + providers: { companyProvider: string }, ) { return { - newsGetWorld: tool({ - description: `Get world news headlines. - -Returns recent global news articles with title, date, source, and URL. -Useful for understanding macro sentiment, geopolitical events, and market-moving headlines.`, - inputSchema: z.object({ - limit: z.number().int().positive().optional().describe('Number of articles to return (default: 20)'), - }), - execute: async ({ limit }) => { - const params: Record = { provider: providers.worldProvider } - if (limit) params.limit = limit - return await newsClient.getWorldNews(params) - }, - }), - newsGetCompany: tool({ description: `Get news for a specific company. diff --git a/src/main.ts b/src/main.ts index 5e56ab97..169ffb24 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,6 @@ import { readFile, writeFile, appendFile, mkdir } from 'fs/promises' import { resolve, dirname } from 'path' -import { Engine } from './core/engine.js' +// Engine removed — AgentCenter is the top-level AI entry point import { loadConfig, loadTradingConfig } from './core/config.js' import type { Plugin, EngineContext, ReconnectResult } from './core/types.js' import { McpPlugin } from './plugins/mcp.js' @@ -22,13 +22,16 @@ import type { AccountSetup, GitExportState, ITradingGit, IPlatform } from './ext import { Brain, createBrainTools } from './extension/brain/index.js' import type { BrainExportState } from './extension/brain/index.js' import { createBrowserTools } from './extension/browser/index.js' -import { OpenBBEquityClient, SymbolIndex } from './openbb/equity/index.js' +import { SymbolIndex } from './openbb/equity/index.js' import { createEquityTools } from './extension/equity/index.js' -import { OpenBBCryptoClient } from './openbb/crypto/index.js' -import { OpenBBCurrencyClient } from './openbb/currency/index.js' -import { OpenBBEconomyClient } from './openbb/economy/index.js' -import { OpenBBCommodityClient } from './openbb/commodity/index.js' -import { OpenBBNewsClient } from './openbb/news/index.js' +import { getSDKExecutor, buildRouteMap, SDKEquityClient, SDKCryptoClient, SDKCurrencyClient, SDKNewsClient } from './openbb/sdk/index.js' +import type { EquityClientLike, CryptoClientLike, CurrencyClientLike, NewsClientLike } from './openbb/sdk/types.js' +import { buildSDKCredentials } from './openbb/credential-map.js' +import { OpenBBEquityClient } from './openbb/equity/client.js' +import { OpenBBCryptoClient } from './openbb/crypto/client.js' +import { OpenBBCurrencyClient } from './openbb/currency/client.js' +import { OpenBBNewsClient } from './openbb/news/client.js' +import { startEmbeddedOpenBBServer } from './openbb/api-server.js' import { createMarketSearchTools } from './extension/market/index.js' import { createNewsTools } from './extension/news/index.js' import { createAnalysisTools } from './extension/analysis-kit/index.js' @@ -36,9 +39,10 @@ import { SessionStore } from './core/session.js' import { ConnectorCenter } from './core/connector-center.js' import { ToolCenter } from './core/tool-center.js' import { AgentCenter } from './core/agent-center.js' -import { ProviderRouter } from './core/ai-provider.js' +import { GenerateRouter } from './core/ai-provider.js' import { VercelAIProvider } from './ai-providers/vercel-ai-sdk/vercel-provider.js' import { ClaudeCodeProvider } from './ai-providers/claude-code/claude-code-provider.js' +import { AgentSdkProvider } from './ai-providers/agent-sdk/agent-sdk-provider.js' import { createEventLog } from './core/event-log.js' import { createCronEngine, createCronListener, createCronTools } from './task/cron/index.js' import { createHeartbeat } from './task/heartbeat/index.js' @@ -220,14 +224,33 @@ async function main() { // ==================== OpenBB Clients ==================== - const providerKeys = config.openbb.providerKeys const { providers } = config.openbb - const equityClient = new OpenBBEquityClient(config.openbb.apiUrl, providers.equity, providerKeys) - const cryptoClient = new OpenBBCryptoClient(config.openbb.apiUrl, providers.crypto, providerKeys) - const currencyClient = new OpenBBCurrencyClient(config.openbb.apiUrl, providers.currency, providerKeys) - const commodityClient = new OpenBBCommodityClient(config.openbb.apiUrl, undefined, providerKeys) - const economyClient = new OpenBBEconomyClient(config.openbb.apiUrl, undefined, providerKeys) - const newsClient = new OpenBBNewsClient(config.openbb.apiUrl, undefined, providerKeys) + + let equityClient: EquityClientLike + let cryptoClient: CryptoClientLike + let currencyClient: CurrencyClientLike + let newsClient: NewsClientLike + + if (config.openbb.dataBackend === 'openbb') { + const url = config.openbb.apiUrl + const keys = config.openbb.providerKeys + equityClient = new OpenBBEquityClient(url, providers.equity, keys) + cryptoClient = new OpenBBCryptoClient(url, providers.crypto, keys) + currencyClient = new OpenBBCurrencyClient(url, providers.currency, keys) + newsClient = new OpenBBNewsClient(url, undefined, keys) + } else { + const executor = getSDKExecutor() + const routeMap = buildRouteMap() + const credentials = buildSDKCredentials(config.openbb.providerKeys) + equityClient = new SDKEquityClient(executor, 'equity', providers.equity, credentials, routeMap) + cryptoClient = new SDKCryptoClient(executor, 'crypto', providers.crypto, credentials, routeMap) + currencyClient = new SDKCurrencyClient(executor, 'currency', providers.currency, credentials, routeMap) + newsClient = new SDKNewsClient(executor, 'news', undefined, credentials, routeMap) + } + + if (config.openbb.apiServer.enabled) { + startEmbeddedOpenBBServer(config.openbb.apiServer.port) + } // ==================== Equity Symbol Index ==================== @@ -256,7 +279,6 @@ async function main() { toolCenter.register(createEquityTools(equityClient), 'equity') let newsTools = createNewsTools(newsClient, { companyProvider: providers.newsCompany, - worldProvider: providers.newsWorld, }) if (config.newsCollector.piggybackOpenBB) { newsTools = wrapNewsToolsForPiggyback(newsTools, newsStore) @@ -275,13 +297,18 @@ async function main() { () => toolCenter.getVercelTools(), instructions, config.agent.maxSteps, - config.compaction, ) - const claudeCodeProvider = new ClaudeCodeProvider(config.compaction, instructions) - const router = new ProviderRouter(vercelProvider, claudeCodeProvider) + const claudeCodeProvider = new ClaudeCodeProvider(instructions) + const agentSdkProvider = new AgentSdkProvider( + () => toolCenter.getVercelTools(), + instructions, + ) + const router = new GenerateRouter(vercelProvider, claudeCodeProvider, agentSdkProvider) - const agentCenter = new AgentCenter(router) - const engine = new Engine({ agentCenter }) + const agentCenter = new AgentCenter({ + router, + compaction: config.compaction, + }) // ==================== Connector Center ==================== @@ -292,7 +319,7 @@ async function main() { await cronEngine.start() const cronSession = new SessionStore('cron/default') await cronSession.restore() - const cronListener = createCronListener({ connectorCenter, eventLog, engine, session: cronSession }) + const cronListener = createCronListener({ connectorCenter, eventLog, agentCenter, session: cronSession }) cronListener.start() console.log('cron: engine + listener started') @@ -300,7 +327,7 @@ async function main() { const heartbeat = createHeartbeat({ config: config.heartbeat, - connectorCenter, cronEngine, eventLog, engine, + connectorCenter, cronEngine, eventLog, agentCenter, }) await heartbeat.start() if (config.heartbeat.enabled) { @@ -473,7 +500,7 @@ async function main() { // ==================== Engine Context ==================== const ctx: EngineContext = { - config, connectorCenter, engine, eventLog, heartbeat, cronEngine, toolCenter, + config, connectorCenter, agentCenter, eventLog, heartbeat, cronEngine, toolCenter, accountManager, getAccountGit: (id: string): ITradingGit | undefined => accountSetups.get(id)?.git, reconnectAccount, diff --git a/src/openbb/api-server.ts b/src/openbb/api-server.ts new file mode 100644 index 00000000..c05904b8 --- /dev/null +++ b/src/openbb/api-server.ts @@ -0,0 +1,26 @@ +/** + * Embedded OpenBB API Server + * + * Starts an OpenBB-compatible HTTP server using opentypebb in-process. + * Exposes the same REST endpoints as the Python OpenBB sidecar, allowing + * external tools to connect to Alice's built-in data engine. + */ + +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { serve } from '@hono/node-server' +import { createExecutor, loadAllRouters } from 'opentypebb' + +export function startEmbeddedOpenBBServer(port: number): void { + const executor = createExecutor() + + const app = new Hono() + app.use(cors()) + app.get('/api/v1/health', (c) => c.json({ status: 'ok' })) + + const rootRouter = loadAllRouters() + rootRouter.mountToHono(app, executor) + + serve({ fetch: app.fetch, port }) + console.log(`[openbb] Embedded API server listening on http://localhost:${port}`) +} diff --git a/src/openbb/credential-map.ts b/src/openbb/credential-map.ts index 0e91eed9..52fb89b8 100644 --- a/src/openbb/credential-map.ts +++ b/src/openbb/credential-map.ts @@ -35,3 +35,21 @@ export function buildCredentialsHeader( return Object.keys(mapped).length > 0 ? JSON.stringify(mapped) : undefined } + +/** + * Build credentials object for OpenTypeBB SDK executor. + * Same mapping as buildCredentialsHeader, but returns a plain object + * instead of a JSON string (executor.execute() accepts Record). + */ +export function buildSDKCredentials( + providerKeys: Record | undefined, +): Record { + if (!providerKeys) return {} + + const mapped: Record = {} + for (const [k, v] of Object.entries(providerKeys)) { + if (v && keyMapping[k]) mapped[keyMapping[k]] = v + } + + return mapped +} diff --git a/src/openbb/equity/SymbolIndex.ts b/src/openbb/equity/SymbolIndex.ts index 040ad648..a9d9a76d 100644 --- a/src/openbb/equity/SymbolIndex.ts +++ b/src/openbb/equity/SymbolIndex.ts @@ -15,7 +15,7 @@ import { readFile, writeFile, mkdir } from 'fs/promises' import { resolve, dirname } from 'path' -import type { OpenBBEquityClient } from './client' +import type { EquityClientLike } from '../sdk/types.js' // ==================== Types ==================== @@ -57,7 +57,7 @@ export class SymbolIndex { * 优先从磁盘缓存加载(<24h),否则从 OpenBB API 拉取全量列表。 * API 失败时降级到过期缓存。全部失败则以空索引启动(不中断)。 */ - async load(client: OpenBBEquityClient): Promise { + async load(client: EquityClientLike): Promise { // 1. 尝试读缓存 const cached = await this.readCache() if (cached && !this.isExpired(cached.cachedAt)) { @@ -124,7 +124,7 @@ export class SymbolIndex { // ==================== Internal ==================== - private async fetchFromApi(client: OpenBBEquityClient): Promise { + private async fetchFromApi(client: EquityClientLike): Promise { const allEntries: SymbolEntry[] = [] const seen = new Set() diff --git a/src/openbb/sdk/base-client.ts b/src/openbb/sdk/base-client.ts new file mode 100644 index 00000000..1d717bab --- /dev/null +++ b/src/openbb/sdk/base-client.ts @@ -0,0 +1,45 @@ +/** + * SDK Base Client + * + * Replaces HTTP fetch with in-process executor.execute() calls. + * All 6 domain-specific SDK clients (equity, crypto, currency, news, economy, commodity) + * extend this class and expose the same method signatures as their HTTP counterparts. + * + * Data flow: + * client.getQuote(params) → this.request('/price/quote', params) + * → resolve model via routeMap: '/equity/price/quote' → 'EquityQuote' + * → executor.execute('fmp', 'EquityQuote', params, credentials) + */ + +import type { QueryExecutor } from 'opentypebb' + +export class SDKBaseClient { + constructor( + protected executor: QueryExecutor, + protected routePrefix: string, // 'equity' | 'crypto' | 'currency' | 'news' | 'economy' | 'commodity' + protected defaultProvider: string | undefined, + protected credentials: Record, + protected routeMap: Map, + ) {} + + protected async request>( + path: string, + params: Record = {}, + ): Promise { + const fullPath = `/${this.routePrefix}${path}` + const model = this.routeMap.get(fullPath) + if (!model) { + throw new Error(`No SDK route for: ${fullPath}`) + } + + const provider = (params.provider as string) ?? this.defaultProvider + if (!provider) { + throw new Error(`No provider specified for: ${fullPath}`) + } + + // Remove 'provider' from params — executor takes it as a separate argument + const { provider: _, ...cleanParams } = params + + return this.executor.execute(provider, model, cleanParams, this.credentials) as Promise + } +} diff --git a/src/openbb/sdk/commodity-client.ts b/src/openbb/sdk/commodity-client.ts new file mode 100644 index 00000000..45b10573 --- /dev/null +++ b/src/openbb/sdk/commodity-client.ts @@ -0,0 +1,36 @@ +/** + * SDK Commodity Client + * + * Drop-in replacement for OpenBBCommodityClient. + * + * NOTE: OpenTypeBB does not yet have commodity routes. These methods will throw + * "No SDK route for: /commodity/..." until the corresponding fetchers are added. + */ + +import { SDKBaseClient } from './base-client.js' + +export class SDKCommodityClient extends SDKBaseClient { + async getSpotPrices(params: Record) { + return this.request('/price/spot', params) + } + + async getPsdData(params: Record) { + return this.request('/psd_data', params) + } + + async getPetroleumStatus(params: Record) { + return this.request('/petroleum_status_report', params) + } + + async getEnergyOutlook(params: Record) { + return this.request('/short_term_energy_outlook', params) + } + + async getPsdReport(params: Record) { + return this.request('/psd_report', params) + } + + async getWeatherBulletins(params: Record = {}) { + return this.request('/weather_bulletins', params) + } +} diff --git a/src/openbb/sdk/crypto-client.ts b/src/openbb/sdk/crypto-client.ts new file mode 100644 index 00000000..82b2a9b1 --- /dev/null +++ b/src/openbb/sdk/crypto-client.ts @@ -0,0 +1,17 @@ +/** + * SDK Crypto Client + * + * Drop-in replacement for OpenBBCryptoClient. + */ + +import { SDKBaseClient } from './base-client.js' + +export class SDKCryptoClient extends SDKBaseClient { + async getHistorical(params: Record) { + return this.request('/price/historical', params) + } + + async search(params: Record) { + return this.request('/search', params) + } +} diff --git a/src/openbb/sdk/currency-client.ts b/src/openbb/sdk/currency-client.ts new file mode 100644 index 00000000..26bc7674 --- /dev/null +++ b/src/openbb/sdk/currency-client.ts @@ -0,0 +1,25 @@ +/** + * SDK Currency Client + * + * Drop-in replacement for OpenBBCurrencyClient. + */ + +import { SDKBaseClient } from './base-client.js' + +export class SDKCurrencyClient extends SDKBaseClient { + async getHistorical(params: Record) { + return this.request('/price/historical', params) + } + + async search(params: Record) { + return this.request('/search', params) + } + + async getReferenceRates(params: Record) { + return this.request('/reference_rates', params) + } + + async getSnapshots(params: Record) { + return this.request('/snapshots', params) + } +} diff --git a/src/openbb/sdk/economy-client.ts b/src/openbb/sdk/economy-client.ts new file mode 100644 index 00000000..653f39cd --- /dev/null +++ b/src/openbb/sdk/economy-client.ts @@ -0,0 +1,187 @@ +/** + * SDK Economy Client + * + * Drop-in replacement for OpenBBEconomyClient. + */ + +import { SDKBaseClient } from './base-client.js' + +export class SDKEconomyClient extends SDKBaseClient { + // ==================== Core ==================== + + async getCalendar(params: Record = {}) { + return this.request('/calendar', params) + } + + async getCPI(params: Record) { + return this.request('/cpi', params) + } + + async getRiskPremium(params: Record) { + return this.request('/risk_premium', params) + } + + async getBalanceOfPayments(params: Record) { + return this.request('/balance_of_payments', params) + } + + async getMoneyMeasures(params: Record = {}) { + return this.request('/money_measures', params) + } + + async getUnemployment(params: Record = {}) { + return this.request('/unemployment', params) + } + + async getCompositeLeadingIndicator(params: Record = {}) { + return this.request('/composite_leading_indicator', params) + } + + async getCountryProfile(params: Record) { + return this.request('/country_profile', params) + } + + async getAvailableIndicators(params: Record = {}) { + return this.request('/available_indicators', params) + } + + async getIndicators(params: Record) { + return this.request('/indicators', params) + } + + async getCentralBankHoldings(params: Record = {}) { + return this.request('/central_bank_holdings', params) + } + + async getSharePriceIndex(params: Record = {}) { + return this.request('/share_price_index', params) + } + + async getHousePriceIndex(params: Record = {}) { + return this.request('/house_price_index', params) + } + + async getInterestRates(params: Record = {}) { + return this.request('/interest_rates', params) + } + + async getRetailPrices(params: Record = {}) { + return this.request('/retail_prices', params) + } + + async getPrimaryDealerPositioning(params: Record = {}) { + return this.request('/primary_dealer_positioning', params) + } + + async getPCE(params: Record = {}) { + return this.request('/pce', params) + } + + async getExportDestinations(params: Record) { + return this.request('/export_destinations', params) + } + + async getPrimaryDealerFails(params: Record = {}) { + return this.request('/primary_dealer_fails', params) + } + + async getDirectionOfTrade(params: Record) { + return this.request('/direction_of_trade', params) + } + + async getFomcDocuments(params: Record = {}) { + return this.request('/fomc_documents', params) + } + + async getTotalFactorProductivity(params: Record = {}) { + return this.request('/total_factor_productivity', params) + } + + // ==================== FRED ==================== + + async fredSearch(params: Record) { + return this.request('/fred_search', params) + } + + async fredSeries(params: Record) { + return this.request('/fred_series', params) + } + + async fredReleaseTable(params: Record) { + return this.request('/fred_release_table', params) + } + + async fredRegional(params: Record) { + return this.request('/fred_regional', params) + } + + // ==================== GDP ==================== + + async getGdpForecast(params: Record = {}) { + return this.request('/gdp/forecast', params) + } + + async getGdpNominal(params: Record = {}) { + return this.request('/gdp/nominal', params) + } + + async getGdpReal(params: Record = {}) { + return this.request('/gdp/real', params) + } + + // ==================== Survey ==================== + + async getBlsSeries(params: Record) { + return this.request('/survey/bls_series', params) + } + + async getBlsSearch(params: Record) { + return this.request('/survey/bls_search', params) + } + + async getSloos(params: Record = {}) { + return this.request('/survey/sloos', params) + } + + async getUniversityOfMichigan(params: Record = {}) { + return this.request('/survey/university_of_michigan', params) + } + + async getEconomicConditionsChicago(params: Record = {}) { + return this.request('/survey/economic_conditions_chicago', params) + } + + async getManufacturingOutlookTexas(params: Record = {}) { + return this.request('/survey/manufacturing_outlook_texas', params) + } + + async getManufacturingOutlookNY(params: Record = {}) { + return this.request('/survey/manufacturing_outlook_ny', params) + } + + async getNonfarmPayrolls(params: Record = {}) { + return this.request('/survey/nonfarm_payrolls', params) + } + + async getInflationExpectations(params: Record = {}) { + return this.request('/survey/inflation_expectations', params) + } + + // ==================== Shipping ==================== + + async getPortInfo(params: Record = {}) { + return this.request('/shipping/port_info', params) + } + + async getPortVolume(params: Record = {}) { + return this.request('/shipping/port_volume', params) + } + + async getChokepointInfo(params: Record = {}) { + return this.request('/shipping/chokepoint_info', params) + } + + async getChokepointVolume(params: Record = {}) { + return this.request('/shipping/chokepoint_volume', params) + } +} diff --git a/src/openbb/sdk/equity-client.ts b/src/openbb/sdk/equity-client.ts new file mode 100644 index 00000000..b60931d2 --- /dev/null +++ b/src/openbb/sdk/equity-client.ts @@ -0,0 +1,306 @@ +/** + * SDK Equity Client + * + * Drop-in replacement for OpenBBEquityClient — same method signatures, + * but calls OpenTypeBB's executor instead of HTTP fetch. + */ + +import { SDKBaseClient } from './base-client.js' + +export class SDKEquityClient extends SDKBaseClient { + // ==================== Price ==================== + + async getHistorical(params: Record) { + return this.request('/price/historical', params) + } + + async getQuote(params: Record) { + return this.request('/price/quote', params) + } + + async getNBBO(params: Record) { + return this.request('/price/nbbo', params) + } + + async getPricePerformance(params: Record) { + return this.request('/price/performance', params) + } + + // ==================== Info ==================== + + async search(params: Record) { + return this.request('/search', params) + } + + async screener(params: Record) { + return this.request('/screener', params) + } + + async getProfile(params: Record) { + return this.request('/profile', params) + } + + async getMarketSnapshots(params: Record = {}) { + return this.request('/market_snapshots', params) + } + + async getHistoricalMarketCap(params: Record) { + return this.request('/historical_market_cap', params) + } + + // ==================== Fundamental ==================== + + async getBalanceSheet(params: Record) { + return this.request('/fundamental/balance', params) + } + + async getBalanceSheetGrowth(params: Record) { + return this.request('/fundamental/balance_growth', params) + } + + async getIncomeStatement(params: Record) { + return this.request('/fundamental/income', params) + } + + async getIncomeStatementGrowth(params: Record) { + return this.request('/fundamental/income_growth', params) + } + + async getCashFlow(params: Record) { + return this.request('/fundamental/cash', params) + } + + async getCashFlowGrowth(params: Record) { + return this.request('/fundamental/cash_growth', params) + } + + async getReportedFinancials(params: Record) { + return this.request('/fundamental/reported_financials', params) + } + + async getFinancialRatios(params: Record) { + return this.request('/fundamental/ratios', params) + } + + async getKeyMetrics(params: Record) { + return this.request('/fundamental/metrics', params) + } + + async getDividends(params: Record) { + return this.request('/fundamental/dividends', params) + } + + async getEarningsHistory(params: Record) { + return this.request('/fundamental/historical_eps', params) + } + + async getEmployeeCount(params: Record) { + return this.request('/fundamental/employee_count', params) + } + + async getManagement(params: Record) { + return this.request('/fundamental/management', params) + } + + async getManagementCompensation(params: Record) { + return this.request('/fundamental/management_compensation', params) + } + + async getFilings(params: Record) { + return this.request('/fundamental/filings', params) + } + + async getSplits(params: Record) { + return this.request('/fundamental/historical_splits', params) + } + + async getTranscript(params: Record) { + return this.request('/fundamental/transcript', params) + } + + async getTrailingDividendYield(params: Record) { + return this.request('/fundamental/trailing_dividend_yield', params) + } + + async getRevenuePerGeography(params: Record) { + return this.request('/fundamental/revenue_per_geography', params) + } + + async getRevenuePerSegment(params: Record) { + return this.request('/fundamental/revenue_per_segment', params) + } + + async getEsgScore(params: Record) { + return this.request('/fundamental/esg_score', params) + } + + async getSearchAttributes(params: Record) { + return this.request('/fundamental/search_attributes', params) + } + + async getLatestAttributes(params: Record) { + return this.request('/fundamental/latest_attributes', params) + } + + async getHistoricalAttributes(params: Record) { + return this.request('/fundamental/historical_attributes', params) + } + + // ==================== Calendar ==================== + + async getCalendarIpo(params: Record = {}) { + return this.request('/calendar/ipo', params) + } + + async getCalendarDividend(params: Record = {}) { + return this.request('/calendar/dividend', params) + } + + async getCalendarSplits(params: Record = {}) { + return this.request('/calendar/splits', params) + } + + async getCalendarEarnings(params: Record = {}) { + return this.request('/calendar/earnings', params) + } + + async getCalendarEvents(params: Record = {}) { + return this.request('/calendar/events', params) + } + + // ==================== Estimates ==================== + + async getPriceTarget(params: Record) { + return this.request('/estimates/price_target', params) + } + + async getAnalystEstimates(params: Record) { + return this.request('/estimates/historical', params) + } + + async getEstimateConsensus(params: Record) { + return this.request('/estimates/consensus', params) + } + + async getAnalystSearch(params: Record) { + return this.request('/estimates/analyst_search', params) + } + + async getForwardSales(params: Record) { + return this.request('/estimates/forward_sales', params) + } + + async getForwardEbitda(params: Record) { + return this.request('/estimates/forward_ebitda', params) + } + + async getForwardEps(params: Record) { + return this.request('/estimates/forward_eps', params) + } + + async getForwardPe(params: Record) { + return this.request('/estimates/forward_pe', params) + } + + // ==================== Discovery ==================== + + async getGainers(params: Record = {}) { + return this.request('/discovery/gainers', params) + } + + async getLosers(params: Record = {}) { + return this.request('/discovery/losers', params) + } + + async getActive(params: Record = {}) { + return this.request('/discovery/active', params) + } + + async getUndervaluedLargeCaps(params: Record = {}) { + return this.request('/discovery/undervalued_large_caps', params) + } + + async getUndervaluedGrowth(params: Record = {}) { + return this.request('/discovery/undervalued_growth', params) + } + + async getAggressiveSmallCaps(params: Record = {}) { + return this.request('/discovery/aggressive_small_caps', params) + } + + async getGrowthTech(params: Record = {}) { + return this.request('/discovery/growth_tech', params) + } + + async getTopRetail(params: Record = {}) { + return this.request('/discovery/top_retail', params) + } + + async getDiscoveryFilings(params: Record = {}) { + return this.request('/discovery/filings', params) + } + + async getLatestFinancialReports(params: Record = {}) { + return this.request('/discovery/latest_financial_reports', params) + } + + // ==================== Ownership ==================== + + async getMajorHolders(params: Record) { + return this.request('/ownership/major_holders', params) + } + + async getInstitutional(params: Record) { + return this.request('/ownership/institutional', params) + } + + async getInsiderTrading(params: Record) { + return this.request('/ownership/insider_trading', params) + } + + async getShareStatistics(params: Record) { + return this.request('/ownership/share_statistics', params) + } + + async getForm13F(params: Record) { + return this.request('/ownership/form_13f', params) + } + + async getGovernmentTrades(params: Record = {}) { + return this.request('/ownership/government_trades', params) + } + + // ==================== Shorts ==================== + + async getFailsToDeliver(params: Record) { + return this.request('/shorts/fails_to_deliver', params) + } + + async getShortVolume(params: Record) { + return this.request('/shorts/short_volume', params) + } + + async getShortInterest(params: Record) { + return this.request('/shorts/short_interest', params) + } + + // ==================== Compare ==================== + + async getPeers(params: Record) { + return this.request('/compare/peers', params) + } + + async getCompareGroups(params: Record = {}) { + return this.request('/compare/groups', params) + } + + async getCompareCompanyFacts(params: Record) { + return this.request('/compare/company_facts', params) + } + + // ==================== DarkPool ==================== + + async getOtc(params: Record) { + return this.request('/darkpool/otc', params) + } +} diff --git a/src/openbb/sdk/executor.ts b/src/openbb/sdk/executor.ts new file mode 100644 index 00000000..8b87cc97 --- /dev/null +++ b/src/openbb/sdk/executor.ts @@ -0,0 +1,16 @@ +/** + * SDK Executor Singleton + * + * Creates and caches a QueryExecutor instance from OpenTypeBB. + * The executor can call any of the 114 fetcher models across 11 providers + * without HTTP overhead. + */ + +import { createExecutor, type QueryExecutor } from 'opentypebb' + +let _executor: QueryExecutor | null = null + +export function getSDKExecutor(): QueryExecutor { + if (!_executor) _executor = createExecutor() + return _executor +} diff --git a/src/openbb/sdk/index.ts b/src/openbb/sdk/index.ts new file mode 100644 index 00000000..bccc38d5 --- /dev/null +++ b/src/openbb/sdk/index.ts @@ -0,0 +1,16 @@ +/** + * OpenTypeBB SDK Integration + * + * Provides in-process data fetching via OpenTypeBB's executor, + * replacing the Python OpenBB sidecar HTTP calls. + */ + +export { getSDKExecutor } from './executor.js' +export { buildRouteMap } from './route-map.js' +export { SDKBaseClient } from './base-client.js' +export { SDKEquityClient } from './equity-client.js' +export { SDKCryptoClient } from './crypto-client.js' +export { SDKCurrencyClient } from './currency-client.js' +export { SDKNewsClient } from './news-client.js' +export { SDKEconomyClient } from './economy-client.js' +export { SDKCommodityClient } from './commodity-client.js' diff --git a/src/openbb/sdk/news-client.ts b/src/openbb/sdk/news-client.ts new file mode 100644 index 00000000..9c99ab95 --- /dev/null +++ b/src/openbb/sdk/news-client.ts @@ -0,0 +1,17 @@ +/** + * SDK News Client + * + * Drop-in replacement for OpenBBNewsClient. + */ + +import { SDKBaseClient } from './base-client.js' + +export class SDKNewsClient extends SDKBaseClient { + async getWorldNews(params: Record = {}) { + return this.request('/world', params) + } + + async getCompanyNews(params: Record) { + return this.request('/company', params) + } +} diff --git a/src/openbb/sdk/route-map.ts b/src/openbb/sdk/route-map.ts new file mode 100644 index 00000000..fa4615df --- /dev/null +++ b/src/openbb/sdk/route-map.ts @@ -0,0 +1,28 @@ +/** + * Route Map Builder + * + * Dynamically builds a path → model name mapping from OpenTypeBB's router system. + * e.g. '/equity/price/quote' → 'EquityQuote' + * + * This mapping allows SDKBaseClient.request(path) to resolve which fetcher model + * to call for each API path, providing a drop-in replacement for HTTP routing. + */ + +import { loadAllRouters } from 'opentypebb' + +let _routeMap: Map | null = null + +export function buildRouteMap(): Map { + if (_routeMap) return _routeMap + + const root = loadAllRouters() + const commands = root.getCommandMap() // Map + const map = new Map() + + for (const [path, cmd] of commands) { + map.set(path, cmd.model) + } + + _routeMap = map + return map +} diff --git a/src/openbb/sdk/types.ts b/src/openbb/sdk/types.ts new file mode 100644 index 00000000..7e7e2dcf --- /dev/null +++ b/src/openbb/sdk/types.ts @@ -0,0 +1,38 @@ +/** + * Duck-typed interfaces for OpenBB clients. + * + * Both the HTTP clients (OpenBBEquityClient etc.) and SDK clients (SDKEquityClient etc.) + * satisfy these interfaces, allowing adapters to accept either implementation. + */ + +export interface EquityClientLike { + search(params: Record): Promise[]> + getHistorical(params: Record): Promise[]> + getProfile(params: Record): Promise[]> + getKeyMetrics(params: Record): Promise[]> + getIncomeStatement(params: Record): Promise[]> + getBalanceSheet(params: Record): Promise[]> + getCashFlow(params: Record): Promise[]> + getFinancialRatios(params: Record): Promise[]> + getEstimateConsensus(params: Record): Promise[]> + getCalendarEarnings(params?: Record): Promise[]> + getInsiderTrading(params: Record): Promise[]> + getGainers(params?: Record): Promise[]> + getLosers(params?: Record): Promise[]> + getActive(params?: Record): Promise[]> +} + +export interface CryptoClientLike { + search(params: Record): Promise[]> + getHistorical(params: Record): Promise[]> +} + +export interface CurrencyClientLike { + search(params: Record): Promise[]> + getHistorical(params: Record): Promise[]> +} + +export interface NewsClientLike { + getWorldNews(params?: Record): Promise[]> + getCompanyNews(params: Record): Promise[]> +} diff --git a/src/task/cron/listener.spec.ts b/src/task/cron/listener.spec.ts index 611c9deb..b6a764c3 100644 --- a/src/task/cron/listener.spec.ts +++ b/src/task/cron/listener.spec.ts @@ -51,7 +51,7 @@ describe('cron listener', () => { listener = createCronListener({ connectorCenter, eventLog, - engine: mockEngine as any, + agentCenter: mockEngine as any, session, }) }) diff --git a/src/task/cron/listener.ts b/src/task/cron/listener.ts index 854a2929..61c2b6cf 100644 --- a/src/task/cron/listener.ts +++ b/src/task/cron/listener.ts @@ -1,9 +1,9 @@ /** * Cron Listener — subscribes to `cron.fire` events from the EventLog - * and routes them through the AI Engine for processing. + * and routes them through the AgentCenter for processing. * * Flow: - * eventLog 'cron.fire' → engine.askWithSession(payload, session) + * eventLog 'cron.fire' → agentCenter.askWithSession(payload, session) * → connectorCenter.notify(reply) * → eventLog 'cron.done' / 'cron.error' * @@ -12,7 +12,7 @@ */ import type { EventLog, EventLogEntry } from '../../core/event-log.js' -import type { Engine } from '../../core/engine.js' +import type { AgentCenter } from '../../core/agent-center.js' import { SessionStore } from '../../core/session.js' import type { ConnectorCenter } from '../../core/connector-center.js' import type { CronFirePayload } from './engine.js' @@ -23,7 +23,7 @@ import { HEARTBEAT_JOB_NAME } from '../heartbeat/heartbeat.js' export interface CronListenerOpts { connectorCenter: ConnectorCenter eventLog: EventLog - engine: Engine + agentCenter: AgentCenter /** Optional: inject a session for testing. Otherwise creates a dedicated cron session. */ session?: SessionStore } @@ -36,7 +36,7 @@ export interface CronListener { // ==================== Factory ==================== export function createCronListener(opts: CronListenerOpts): CronListener { - const { connectorCenter, eventLog, engine } = opts + const { connectorCenter, eventLog, agentCenter } = opts const session = opts.session ?? new SessionStore('cron/default') let unsubscribe: (() => void) | null = null @@ -59,7 +59,7 @@ export function createCronListener(opts: CronListenerOpts): CronListener { try { // Ask the AI engine with the cron payload - const result = await engine.askWithSession(payload.payload, session, { + const result = await agentCenter.askWithSession(payload.payload, session, { historyPreamble: 'The following is the recent cron session conversation. This is an automated cron job execution.', }) diff --git a/src/task/heartbeat/heartbeat.spec.ts b/src/task/heartbeat/heartbeat.spec.ts index 57faef7b..b2421e09 100644 --- a/src/task/heartbeat/heartbeat.spec.ts +++ b/src/task/heartbeat/heartbeat.spec.ts @@ -85,7 +85,7 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), connectorCenter, cronEngine, eventLog, - engine: mockEngine as any, + agentCenter: mockEngine as any, session, }) @@ -101,7 +101,7 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig({ every: '30m' }), connectorCenter, cronEngine, eventLog, - engine: mockEngine as any, + agentCenter: mockEngine as any, session, }) @@ -112,7 +112,7 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig({ every: '1h' }), connectorCenter, cronEngine, eventLog, - engine: mockEngine as any, + agentCenter: mockEngine as any, session, }) @@ -127,7 +127,7 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), connectorCenter, cronEngine, eventLog, - engine: mockEngine as any, + agentCenter: mockEngine as any, session, }) @@ -154,7 +154,7 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), connectorCenter, cronEngine, eventLog, - engine: mockEngine as any, + agentCenter: mockEngine as any, session, }) await heartbeat.start() @@ -183,7 +183,7 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), connectorCenter, cronEngine, eventLog, - engine: mockEngine as any, + agentCenter: mockEngine as any, session, }) await heartbeat.start() @@ -216,7 +216,7 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), connectorCenter, cronEngine, eventLog, - engine: mockEngine as any, + agentCenter: mockEngine as any, session, }) await heartbeat.start() @@ -236,7 +236,7 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), connectorCenter, cronEngine, eventLog, - engine: mockEngine as any, + agentCenter: mockEngine as any, session, }) await heartbeat.start() @@ -266,7 +266,7 @@ describe('heartbeat', () => { activeHours: { start: '09:00', end: '22:00', timezone: 'local' }, }), connectorCenter, cronEngine, eventLog, - engine: mockEngine as any, + agentCenter: mockEngine as any, session, now: () => fakeNow, }) @@ -299,7 +299,7 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), connectorCenter, cronEngine, eventLog, - engine: mockEngine as any, + agentCenter: mockEngine as any, session, }) await heartbeat.start() @@ -332,7 +332,7 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), connectorCenter, cronEngine, eventLog, - engine: mockEngine as any, + agentCenter: mockEngine as any, session, }) await heartbeat.start() @@ -358,7 +358,7 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), connectorCenter, cronEngine, eventLog, - engine: mockEngine as any, + agentCenter: mockEngine as any, session, }) await heartbeat.start() @@ -382,7 +382,7 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), connectorCenter, cronEngine, eventLog, - engine: mockEngine as any, + agentCenter: mockEngine as any, session, }) await heartbeat.start() @@ -402,7 +402,7 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), connectorCenter, cronEngine, eventLog, - engine: mockEngine as any, + agentCenter: mockEngine as any, session, }) await heartbeat.start() @@ -420,7 +420,7 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: true }), connectorCenter, cronEngine, eventLog, - engine: mockEngine as any, + agentCenter: mockEngine as any, session, }) await heartbeat.start() @@ -439,7 +439,7 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), connectorCenter, cronEngine, eventLog, - engine: mockEngine as any, + agentCenter: mockEngine as any, session, }) await heartbeat.start() @@ -459,7 +459,7 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), connectorCenter, cronEngine, eventLog, - engine: mockEngine as any, + agentCenter: mockEngine as any, session, }) await heartbeat.start() diff --git a/src/task/heartbeat/heartbeat.ts b/src/task/heartbeat/heartbeat.ts index 1e282ac7..5207c9ad 100644 --- a/src/task/heartbeat/heartbeat.ts +++ b/src/task/heartbeat/heartbeat.ts @@ -4,7 +4,7 @@ * Registers a cron job (`__heartbeat__`) that fires at a configured interval. * When fired, calls the AI engine and filters the response: * 1. Active hours guard — skip if outside configured window - * 2. AI call — engine.askWithSession(prompt, heartbeatSession) + * 2. AI call — agentCenter.askWithSession(prompt, heartbeatSession) * 3. Ack token filter — skip if AI says "nothing to report" * 4. Dedup — skip if same text was sent within 24h * 5. Send — connectorCenter.notify(text) @@ -16,7 +16,7 @@ */ import type { EventLog, EventLogEntry } from '../../core/event-log.js' -import type { Engine } from '../../core/engine.js' +import type { AgentCenter } from '../../core/agent-center.js' import { SessionStore } from '../../core/session.js' import type { ConnectorCenter } from '../../core/connector-center.js' import { writeConfigSection } from '../../core/config.js' @@ -78,7 +78,7 @@ export interface HeartbeatOpts { connectorCenter: ConnectorCenter cronEngine: CronEngine eventLog: EventLog - engine: Engine + agentCenter: AgentCenter /** Optional: inject a session for testing. */ session?: SessionStore /** Inject clock for testing. */ @@ -97,7 +97,7 @@ export interface Heartbeat { // ==================== Factory ==================== export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { - const { config, connectorCenter, cronEngine, eventLog, engine } = opts + const { config, connectorCenter, cronEngine, eventLog, agentCenter } = opts const session = opts.session ?? new SessionStore('heartbeat') const now = opts.now ?? Date.now @@ -130,7 +130,7 @@ export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { } // 2. Call AI - const result = await engine.askWithSession(payload.payload, session, { + const result = await agentCenter.askWithSession(payload.payload, session, { historyPreamble: 'The following is the recent heartbeat conversation history.', }) const durationMs = now() - startMs diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b513c1fc..929bc023 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Routes, Route, Navigate } from 'react-router-dom' +import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { Sidebar } from './components/Sidebar' import { ChatPage } from './pages/ChatPage' import { PortfolioPage } from './pages/PortfolioPage' @@ -36,6 +36,7 @@ export const ROUTES: Record = { export function App() { const [sseConnected, setSseConnected] = useState(false) const [sidebarOpen, setSidebarOpen] = useState(false) + const location = useLocation() return (
@@ -58,20 +59,22 @@ export function App() { Open Alice
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
) diff --git a/ui/src/api/channels.ts b/ui/src/api/channels.ts new file mode 100644 index 00000000..1627a69a --- /dev/null +++ b/ui/src/api/channels.ts @@ -0,0 +1,57 @@ +import { headers } from './client' +import type { WebChannel, VercelAiSdkOverride, AgentSdkOverride } from './types' + +export interface ChannelListItem { + id: string + label: string + systemPrompt?: string + provider?: 'claude-code' | 'vercel-ai-sdk' | 'agent-sdk' + vercelAiSdk?: VercelAiSdkOverride + agentSdk?: AgentSdkOverride + disabledTools?: string[] +} + +export const channelsApi = { + async list(): Promise<{ channels: ChannelListItem[] }> { + const res = await fetch('/api/channels') + if (!res.ok) throw new Error('Failed to load channels') + return res.json() + }, + + async create(data: Omit & { id: string }): Promise<{ channel: ChannelListItem }> { + const res = await fetch('/api/channels', { + method: 'POST', + headers, + body: JSON.stringify(data), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + throw new Error(err.error || res.statusText) + } + return res.json() + }, + + async update(id: string, data: Partial>): Promise<{ channel: ChannelListItem }> { + const res = await fetch(`/api/channels/${encodeURIComponent(id)}`, { + method: 'PUT', + headers, + body: JSON.stringify(data), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + throw new Error(err.error || res.statusText) + } + return res.json() + }, + + async remove(id: string): Promise { + const res = await fetch(`/api/channels/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + throw new Error(err.error || res.statusText) + } + }, +} diff --git a/ui/src/api/chat.ts b/ui/src/api/chat.ts index e5609f41..aa333ebd 100644 --- a/ui/src/api/chat.ts +++ b/ui/src/api/chat.ts @@ -2,11 +2,11 @@ import { headers } from './client' import type { ChatResponse, ChatHistoryItem } from './types' export const chatApi = { - async send(message: string): Promise { + async send(message: string, channelId?: string): Promise { const res = await fetch('/api/chat', { method: 'POST', headers, - body: JSON.stringify({ message }), + body: JSON.stringify({ message, ...(channelId ? { channelId } : {}) }), }) if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })) @@ -15,14 +15,20 @@ export const chatApi = { return res.json() }, - async history(limit = 100): Promise<{ messages: ChatHistoryItem[] }> { - const res = await fetch(`/api/chat/history?limit=${limit}`) + async history(limit = 100, channel?: string): Promise<{ messages: ChatHistoryItem[] }> { + const params = new URLSearchParams({ limit: String(limit) }) + if (channel) params.set('channel', channel) + const res = await fetch(`/api/chat/history?${params}`) if (!res.ok) throw new Error('Failed to load history') return res.json() }, - connectSSE(onMessage: (data: { type: string; kind?: string; text: string; media?: Array<{ type: string; url: string }> }) => void): EventSource { - const es = new EventSource('/api/chat/events') + connectSSE( + onMessage: (data: { type: string; kind?: string; text: string; media?: Array<{ type: string; url: string }> }) => void, + channel?: string, + ): EventSource { + const url = channel ? `/api/chat/events?channel=${encodeURIComponent(channel)}` : '/api/chat/events' + const es = new EventSource(url) es.onmessage = (event) => { try { const data = JSON.parse(event.data) diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 7f65d65e..c5307c1a 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -11,6 +11,7 @@ import { tradingApi } from './trading' import { openbbApi } from './openbb' import { devApi } from './dev' import { toolsApi } from './tools' +import { channelsApi } from './channels' export const api = { chat: chatApi, config: configApi, @@ -21,13 +22,17 @@ export const api = { openbb: openbbApi, dev: devApi, tools: toolsApi, + channels: channelsApi, } // Re-export all types for convenience export type { + WebChannel, + VercelAiSdkOverride, ChatMessage, ChatResponse, ToolCall, + StreamingToolCall, ChatHistoryItem, AppConfig, AIProviderConfig, diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index ca67a3ab..8d46e5b6 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -1,3 +1,28 @@ +// ==================== Channels ==================== + +export interface VercelAiSdkOverride { + provider: string + model: string + baseUrl?: string + apiKey?: string +} + +export interface AgentSdkOverride { + model?: string + baseUrl?: string + apiKey?: string +} + +export interface WebChannel { + id: string + label: string + systemPrompt?: string + provider?: 'claude-code' | 'vercel-ai-sdk' | 'agent-sdk' + vercelAiSdk?: VercelAiSdkOverride + agentSdk?: AgentSdkOverride + disabledTools?: string[] +} + // ==================== Chat ==================== export interface ChatMessage { @@ -17,6 +42,14 @@ export interface ToolCall { result?: string } +export interface StreamingToolCall { + id: string + name: string + input: unknown + status: 'running' | 'done' + result?: string +} + export type ChatHistoryItem = | { kind: 'text'; role: 'user' | 'assistant'; text: string; timestamp?: string; metadata?: Record; media?: Array<{ type: string; url: string }> } | { kind: 'tool_calls'; calls: ToolCall[]; timestamp?: string } diff --git a/ui/src/components/ChannelConfigModal.tsx b/ui/src/components/ChannelConfigModal.tsx new file mode 100644 index 00000000..6ff2a482 --- /dev/null +++ b/ui/src/components/ChannelConfigModal.tsx @@ -0,0 +1,313 @@ +import { useState, useEffect } from 'react' +import { api } from '../api' +import type { ChannelListItem } from '../api/channels' +import type { ToolInfo } from '../api/tools' + +interface ChannelConfigModalProps { + channel: ChannelListItem + onClose: () => void + onSaved: (updated: ChannelListItem) => void +} + +export function ChannelConfigModal({ channel, onClose, onSaved }: ChannelConfigModalProps) { + const [label, setLabel] = useState(channel.label) + const [systemPrompt, setSystemPrompt] = useState(channel.systemPrompt ?? '') + const [provider, setProvider] = useState(channel.provider ?? '') + const [disabledTools, setDisabledTools] = useState>(new Set(channel.disabledTools ?? [])) + const [tools, setTools] = useState([]) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + // Vercel AI SDK override state + const [vModelProvider, setVModelProvider] = useState(channel.vercelAiSdk?.provider ?? '') + const [vModel, setVModel] = useState(channel.vercelAiSdk?.model ?? '') + const [vBaseUrl, setVBaseUrl] = useState(channel.vercelAiSdk?.baseUrl ?? '') + const [vApiKey, setVApiKey] = useState(channel.vercelAiSdk?.apiKey ?? '') + + // Agent SDK override state + const [aModel, setAModel] = useState(channel.agentSdk?.model ?? '') + const [aBaseUrl, setABaseUrl] = useState(channel.agentSdk?.baseUrl ?? '') + const [aApiKey, setAApiKey] = useState(channel.agentSdk?.apiKey ?? '') + + const showVercelConfig = provider === 'vercel-ai-sdk' + const showAgentSdkConfig = provider === 'agent-sdk' + + useEffect(() => { + api.tools.load().then(({ inventory }) => setTools(inventory)).catch(() => {}) + }, []) + + const handleSave = async () => { + setSaving(true) + setError('') + try { + const vercelAiSdk = showVercelConfig && vModelProvider && vModel + ? { + provider: vModelProvider, + model: vModel, + ...(vBaseUrl ? { baseUrl: vBaseUrl } : {}), + ...(vApiKey ? { apiKey: vApiKey } : {}), + } + : undefined + + const agentSdk = showAgentSdkConfig && aModel + ? { + model: aModel, + ...(aBaseUrl ? { baseUrl: aBaseUrl } : {}), + ...(aApiKey ? { apiKey: aApiKey } : {}), + } + : undefined + + const { channel: updated } = await api.channels.update(channel.id, { + label: label.trim() || channel.label, + systemPrompt: systemPrompt.trim() || undefined, + provider: (provider as 'claude-code' | 'vercel-ai-sdk' | 'agent-sdk') || undefined, + vercelAiSdk: vercelAiSdk ?? (null as unknown as undefined), + agentSdk: agentSdk ?? (null as unknown as undefined), + disabledTools: disabledTools.size > 0 ? [...disabledTools] : undefined, + }) + onSaved(updated) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save') + } finally { + setSaving(false) + } + } + + const toggleTool = (name: string) => { + setDisabledTools((prev) => { + const next = new Set(prev) + if (next.has(name)) next.delete(name) + else next.add(name) + return next + }) + } + + // Group tools by group name + const toolGroups = tools.reduce>((acc, t) => { + ;(acc[t.group] ??= []).push(t) + return acc + }, {}) + + const inputClass = 'w-full text-sm px-3 py-2 rounded-lg border border-border bg-bg-secondary text-text placeholder:text-text-muted focus:outline-none focus:border-accent' + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

+ # + {channel.id} +

+ +
+ + {/* Body */} +
+ {/* Label */} +
+ + setLabel(e.target.value)} + className={inputClass} + /> +
+ + {/* System Prompt */} +
+ +