diff --git a/.github/requirements-build.txt b/.github/requirements-build.txt index a4cc3320..e74bae81 100644 --- a/.github/requirements-build.txt +++ b/.github/requirements-build.txt @@ -2,6 +2,6 @@ # floor bump (or a newer compatible release) is enough to pick up updates. # Builds then run `python -m build --no-isolation` so PEP 517 isolation # cannot pull a different backend than what we just installed. -build>=1.5.0 -hatchling>=1.31.0 +build>=1.6.1 +hatchling>=1.32.0 hatch-vcs>=0.5.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cde562e..c0b8a2b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,9 +36,9 @@ jobs: python-version: ${{ matrix.python }} - name: ⚡ Install uv (with cache) - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "0.12.3" + version: "0.12.13" enable-cache: true - name: ▶️ Run Tests @@ -88,9 +88,9 @@ jobs: python-version: "3.14" - name: ⚡ Install uv - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "0.12.3" + version: "0.12.13" - name: 📊 Run all tests with coverage env: @@ -145,9 +145,9 @@ jobs: python-version: "3.14" - name: ⚡ Install uv - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "0.12.3" + version: "0.12.13" - name: 📊 Extras coverage gate (lc / mcp / Redis) env: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1cd56f73..914d54ef 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -30,12 +30,12 @@ jobs: persist-credentials: false - name: 🔍 Initialize CodeQL - uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: languages: python build-mode: none - name: 📈 Perform CodeQL Analysis - uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: category: "/language:python" diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index c50e8488..1fc40b89 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -55,9 +55,9 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: ⚡ Install uv (with cache) - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "0.12.3" + version: "0.12.13" enable-cache: true - name: 📦 Install build frontend and backend (version floors) diff --git a/.github/workflows/document.yml b/.github/workflows/document.yml index 3c234a3b..e65d1cc9 100644 --- a/.github/workflows/document.yml +++ b/.github/workflows/document.yml @@ -50,9 +50,9 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Install uv - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "0.12.3" + version: "0.12.13" enable-cache: true - name: Cache uv dependencies @@ -132,7 +132,7 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 - name: Generate deployment summary env: diff --git a/.github/workflows/publish-testpypi.yml b/.github/workflows/publish-testpypi.yml index 7648abe3..b7a2d352 100644 --- a/.github/workflows/publish-testpypi.yml +++ b/.github/workflows/publish-testpypi.yml @@ -61,9 +61,9 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: ⚡ Install uv - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "0.12.3" + version: "0.12.13" enable-cache: false - name: 📦 Install build frontend and backend (version floors) @@ -321,9 +321,9 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: ⚡ Install uv - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "0.12.3" + version: "0.12.13" enable-cache: false - name: 📦 Build wheel & sdist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 721c3c3f..72c07a77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,9 +59,9 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: ⚡ Install uv - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "0.12.3" + version: "0.12.13" enable-cache: false - name: 📦 Install build frontend and backend (version floors) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ca9274c6..4fbbbf46 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,9 +18,9 @@ repos: # ───────── Ruff (lint / fix / format) ──────────────────────────────────── # Ruff replaces black here: black 24.x and ruff's formatter disagree on # assert-with-message wrapping, so running both fights over the same lines. - # Keep this rev in sync with the ruff pin in uv.lock. + # Keep this rev in sync with the ruff floor in pyproject.toml. - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.2 + rev: v0.16.7 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/CHANGELOG.md b/CHANGELOG.md index 3142c134..a1e5f65e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- **Optional FMP parameters across 27 sync/async method pairs (Refs #396).** + Add holiday date ranges, market-hours timestamps, exchange-list expansion, + screener average-volume bounds, share-float pagination, CIK/employee limits, + historical market-cap dates and limits, symbol-change filters, extended + intraday hours, earnings report-time inclusion, economic indicator dates and + calendar country filters, beneficial-ownership limits, calendar pagination, + ESG benchmark years, Senate/House pagination, alternative-asset intraday + dates, and SIC industry/code filters. Both custom DCF methods expose all 18 + documented numeric assumptions. Existing tool schemas and parameter hints + include the controls; a provider-derived fixture and catalog guard detect + missing optional parameters. See `docs/api/optional-parameters.md` for the + complete Python-to-FMP mapping. + +### Changed + +- **Preserve existing signatures and requests.** New arguments are keyword-only + with `None` defaults and are omitted before request validation. Existing + positional calls, types, return shapes, and endpoint defaults remain intact, + including CIK search's limit of 50. Explicit `False` and `0` are forwarded. +- **Refresh every dependency group to current stable releases (2026-09-13).** + Upgrade Pydantic 2.13.4 → 2.13.5; MCP 2.0.0 → 2.2.0; langchain-core + 1.5.3 → 1.6.3; langchain-openai 1.4.2 → 1.6.2; LangGraph 1.2.10 → 1.2.11; + OpenAI 2.53.0 → 3.13.0; tiktoken 0.13.0 → 0.14.0; and tomli 2.0.1 → 2.4.1. + OpenAI 3 uses HTTPX2 internally; FMP requests continue through HTTPX 0.28.1. + The MCP compatibility loader retains its 1.x fallback while type-checking + against the 2.2 stubs. The package still supports Python 3.10–3.14. +- **Build, development, documentation, and CI tools.** Upgrade Hatchling + 1.31.0 → 1.32.0, build 1.5.0 → 1.6.1, uv 0.12.3 → 0.12.13, Ruff + 0.16.2 → 0.16.7 (including the hook), mypy 2.3.0 → 2.3.1, nox + 2026.7.11 → 2026.8.17, coverage 7.15.4 → 7.16.0, pre-commit 4.6.1 → 4.6.2, + python-dotenv 1.2.2 → 1.2.3, responses 0.26.2 → 0.26.3, and + mkdocstrings-python 2.0.5 → 2.0.8. Align the security session's Bandit floor + with 1.9.4. Dependencies already at the latest stable release are retained; + prereleases are excluded and `uv.lock` remains uncommitted. +- **SHA-pinned GitHub Actions.** setup-uv 10.0.1 → 10.1.0, deploy-pages + 5.0.0 → 5.0.1, and CodeQL 4.37.7 → 4.38.0; other actions and hooks were + checked and remain at their latest stable versions. + +### Fixed + +- **Exchange-variant queries use `symbol`.** Keep the public + `search_exchange_variants(query)` signature while mapping it to FMP's + `symbol` key. The old wire name could silently return Apple variants for a + Microsoft query. +- **Restore `make ci` and `make check-all`.** Add the missing `ci_check` nox + aggregator for the existing lint, typing, security, core/extras coverage, + and import-smoke gates. Coverage thresholds and hosted CI policy are unchanged. +- **Document remaining provider qualification.** Date filters do not repair + NYSE 2027/2028 calendar completeness, GDP date-filter behavior, SEC profile + identifier behavior, or overlapping pages. These remain tracked in #396; + the SDK does not alter provider records or infer completeness from HTTP 200. + ## [2.7.1] - 2026-08-20 Patch of leftover 2.7.0 CI, docs, and tests that landed unlabeled on diff --git a/docs/api/optional-parameters.md b/docs/api/optional-parameters.md new file mode 100644 index 00000000..1d7b8d56 --- /dev/null +++ b/docs/api/optional-parameters.md @@ -0,0 +1,125 @@ +# Optional request parameters + +The sync and async clients accept the same filters. The additions below are +keyword-only and default to `None`. Existing argument order, positional calls, +defaults, and return types are preserved. Unset additions are omitted before +request validation, so existing endpoint defaults still apply: for example, +`market.search_by_cik("320193")` still requests `limit=50`. + +Pass `False` or `0` explicitly when needed; neither is treated as unset. Limits +and pages are sent to FMP as requested. The SDK does not automatically paginate, +deduplicate overlapping pages, widen date ranges, or repair calendar records. + +```python +from datetime import date + +from fmp_data import FMPDataClient + +with FMPDataClient.from_env() as client: + holidays = client.market.get_holidays_by_exchange( + "NYSE", from_date=date(2026, 1, 1), to_date=date(2026, 12, 31) + ) + companies = client.market.search_by_cik("320193", limit=2) + prices = client.company.get_intraday_prices( + "AAPL", + "5min", + from_date=date(2026, 9, 10), + to_date=date(2026, 9, 11), + extended=False, + ) + valuation = client.fundamental.get_custom_discounted_cash_flow( + "MSFT", risk_free_rate=3.5, revenue_growth_pct=5.0 + ) +``` + +Use `await client..(...)` with `AsyncFMPDataClient` and the same +arguments. Dates are `datetime.date`; timestamps are Unix seconds. Country and +SIC codes are strings. FMP documents `invalid` as a string, so use `"false"` or +`"true"` rather than a Python boolean. Country values follow FMP's vocabulary; +the audit verified `US` and `UK`, while `GB` returned no rows. + +## Added controls + +Provider documentation checked on 2026-09-13. Each row links to its source. + +| Client method | New Python arguments | FMP query keys | +| --- | --- | --- | +| [`market.get_holidays_by_exchange`](https://site.financialmodelingprep.com/developer/docs/stable/holidays-by-exchange) | `from_date`, `to_date` | `from`, `to` | +| [`market.get_company_screener`](https://site.financialmodelingprep.com/developer/docs/stable/search-company-screener) | `avg_volume_more_than`, `avg_volume_less_than` | `avgVolumeMoreThan`, `avgVolumeLowerThan` | +| [`market.get_market_hours`](https://site.financialmodelingprep.com/developer/docs/stable/exchange-market-hours) | `timestamp` | `timestamp` | +| [`market.get_all_exchange_market_hours`](https://site.financialmodelingprep.com/developer/docs/stable/all-exchange-market-hours) | `timestamp` | `timestamp` | +| [`market.get_all_shares_float`](https://site.financialmodelingprep.com/developer/docs/stable/all-shares-float) | `page`, `limit` | `page`, `limit` | +| [`market.get_available_exchanges`](https://site.financialmodelingprep.com/developer/docs/stable/available-exchanges) | `extended` | `extended` | +| [`market.search_by_cik`](https://site.financialmodelingprep.com/developer/docs/stable/search-cik) | `limit` | `limit` | +| [`company.get_employee_count`](https://site.financialmodelingprep.com/developer/docs/stable/employee-count) | `limit` | `limit` | +| [`company.get_historical_market_cap`](https://site.financialmodelingprep.com/developer/docs/stable/historical-market-cap) | `from_date`, `to_date`, `limit` | `from`, `to`, `limit` | +| [`company.get_symbol_changes`](https://site.financialmodelingprep.com/developer/docs/stable/symbol-changes-list) | `invalid`, `limit` | `invalid`, `limit` | +| [`company.get_intraday_prices`](https://site.financialmodelingprep.com/developer/docs/stable/intraday-5-min) | `extended` | `extended` | +| [`company.get_earnings`](https://site.financialmodelingprep.com/developer/docs/stable/earnings-company) | `include_report_times` | `includeReportTimes` | +| [`economics.get_economic_indicators`](https://site.financialmodelingprep.com/developer/docs/stable/economics-indicators) | `start_date`, `end_date` | `from`, `to` | +| [`economics.get_economic_calendar`](https://site.financialmodelingprep.com/developer/docs/stable/economics-calendar) | `country` | `country` | +| [`fundamental.get_custom_discounted_cash_flow`](https://site.financialmodelingprep.com/developer/docs/stable/custom-dcf-advanced) | 18 custom DCF assumptions (below) | Corresponding camelCase keys (below) | +| [`fundamental.get_custom_levered_dcf`](https://site.financialmodelingprep.com/developer/docs/stable/custom-dcf-levered) | 18 custom DCF assumptions (below) | Corresponding camelCase keys (below) | +| [`institutional.get_beneficial_ownership`](https://site.financialmodelingprep.com/developer/docs/stable/acquisition-ownership) | `limit` | `limit` | +| [`intelligence.get_earnings_calendar`](https://site.financialmodelingprep.com/developer/docs/stable/earnings-calendar) | `page` | `page` | +| [`intelligence.get_dividends_calendar`](https://site.financialmodelingprep.com/developer/docs/stable/dividends-calendar) | `page` | `page` | +| [`intelligence.get_stock_splits_calendar`](https://site.financialmodelingprep.com/developer/docs/stable/splits-calendar) | `page` | `page` | +| [`intelligence.get_esg_benchmark`](https://site.financialmodelingprep.com/developer/docs/stable/esg-benchmark) | `year` | `year` | +| [`intelligence.get_senate_trading`](https://site.financialmodelingprep.com/developer/docs/stable/senate-trading) | `page`, `limit` | `page`, `limit` | +| [`intelligence.get_house_disclosure`](https://site.financialmodelingprep.com/developer/docs/stable/house-trading) | `page`, `limit` | `page`, `limit` | +| [`alternative.get_crypto_intraday`](https://site.financialmodelingprep.com/developer/docs/stable/cryptocurrency-intraday-1-hour) | `start_date`, `end_date` | `from`, `to` | +| [`alternative.get_forex_intraday`](https://site.financialmodelingprep.com/developer/docs/stable/forex-intraday-1-hour) | `start_date`, `end_date` | `from`, `to` | +| [`alternative.get_commodity_intraday`](https://site.financialmodelingprep.com/developer/docs/stable/commodities-intraday-1-hour) | `start_date`, `end_date` | `from`, `to` | +| [`sec.get_sic_codes`](https://site.financialmodelingprep.com/developer/docs/stable/industry-classification-list) | `industry_title`, `sic_code` | `industryTitle`, `sicCode` | + +## Custom DCF assumptions + +Both custom DCF methods expose all 18 assumptions as `float | None`. Values are +forwarded without unit conversion; percentage fields use the units documented +by FMP. Omitting an assumption leaves its selection to the provider. + +| Python argument | FMP query key | +| --- | --- | +| `revenue_growth_pct` | `revenueGrowthPct` | +| `ebitda_pct` | `ebitdaPct` | +| `depreciation_and_amortization_pct` | `depreciationAndAmortizationPct` | +| `cash_and_short_term_investments_pct` | `cashAndShortTermInvestmentsPct` | +| `receivables_pct` | `receivablesPct` | +| `inventories_pct` | `inventoriesPct` | +| `payable_pct` | `payablePct` | +| `ebit_pct` | `ebitPct` | +| `capital_expenditure_pct` | `capitalExpenditurePct` | +| `operating_cash_flow_pct` | `operatingCashFlowPct` | +| `selling_general_and_administrative_expenses_pct` | `sellingGeneralAndAdministrativeExpensesPct` | +| `tax_rate` | `taxRate` | +| `long_term_growth_rate` | `longTermGrowthRate` | +| `cost_of_debt` | `costOfDebt` | +| `cost_of_equity` | `costOfEquity` | +| `market_risk_premium` | `marketRiskPremium` | +| `beta` | `beta` | +| `risk_free_rate` | `riskFreeRate` | + +## Tool integrations + +Existing LangChain and MCP catalog entries expose these optional controls with +matching types and hints. The catalog inventory is unchanged: the screener, +available-exchanges, and company earnings convenience methods remain SDK-only; +applications can wrap their public methods explicitly. The intelligence earnings +tool already exposed `include_report_times`. + +`market.search_exchange_variants(query)` retains its public argument name, but +now sends FMP's required `symbol` query key. For example, pass `"MSFT"` to search +Microsoft's exchange variants. The old `query=MSFT` wire request returned Apple +variants in the live audit. + +## Provider qualification + +Exposing a filter is separate from verifying the returned dataset. The audit +found overlapping calendar pages, an empty date-bounded GDP response despite +an in-range row in the unbounded series, and unresolved SEC profile identifier +behavior. NYSE 2027/2028 holiday and early-close completeness also remains under +investigation. No calendar flags, provider payloads, or date boundaries are +rewritten. An empty successful response does not establish completeness. + +See [issue #396](https://github.com/MehdiZare/fmp-data/issues/396) for the provider +evidence and remaining qualification work. diff --git a/docs/api/reference.md b/docs/api/reference.md index d2fe078d..a9619c68 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -28,6 +28,11 @@ batch bulk accept only `PeriodFiscal`. Some company series take only `structure=` takes `Structure` (`flat` / `nested`); live `/stable` returns the same list-of-objects for both. +## Optional request filters + +See [optional request parameters](optional-parameters.md) for the new filters, +backward-compatible defaults, custom DCF assumptions, and provider limitations. + ## Main Clients ### Sync Client diff --git a/docs/mcp/tools.md b/docs/mcp/tools.md index 74e0c707..2da674b0 100644 --- a/docs/mcp/tools.md +++ b/docs/mcp/tools.md @@ -1,5 +1,7 @@ # FMP Data MCP Tools Reference +For optional filters and defaults, see [request parameters](../api/optional-parameters.md). + This document lists the MCP tools available in this release. Tools are organized by client module and include descriptions. diff --git a/fmp_data/alternative/async_client.py b/fmp_data/alternative/async_client.py index acccf8a2..8c6c8693 100644 --- a/fmp_data/alternative/async_client.py +++ b/fmp_data/alternative/async_client.py @@ -105,12 +105,30 @@ async def get_crypto_historical( ) async def get_crypto_intraday( - self, symbol: str, interval: Interval = "5min" + self, + symbol: str, + interval: Interval = "5min", + *, + start_date: date | None = None, + end_date: date | None = None, ) -> list[CryptoIntradayPrice]: - """Get cryptocurrency intraday prices""" + """Get cryptocurrency intraday prices + + Additional keyword arguments (None preserves the existing request): + start_date: Start date passed to FMP; boundary semantics depend on the + endpoint. + end_date: End date passed to FMP; boundary semantics depend on the endpoint. + """ + optional_params = { + "start_date": start_date, + "end_date": end_date, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( await self.client.request_async( - CRYPTO_INTRADAY, symbol=symbol, interval=interval + CRYPTO_INTRADAY, symbol=symbol, interval=interval, **optional_params ), CryptoIntradayPrice, ) @@ -162,12 +180,30 @@ async def get_forex_historical( ) async def get_forex_intraday( - self, symbol: str, interval: Interval = "5min" + self, + symbol: str, + interval: Interval = "5min", + *, + start_date: date | None = None, + end_date: date | None = None, ) -> list[ForexIntradayPrice]: - """Get forex intraday prices""" + """Get forex intraday prices + + Additional keyword arguments (None preserves the existing request): + start_date: Start date passed to FMP; boundary semantics depend on the + endpoint. + end_date: End date passed to FMP; boundary semantics depend on the endpoint. + """ + optional_params = { + "start_date": start_date, + "end_date": end_date, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( await self.client.request_async( - FOREX_INTRADAY, symbol=symbol, interval=interval + FOREX_INTRADAY, symbol=symbol, interval=interval, **optional_params ), ForexIntradayPrice, ) @@ -221,12 +257,30 @@ async def get_commodity_historical( ) async def get_commodity_intraday( - self, symbol: str, interval: Interval = "5min" + self, + symbol: str, + interval: Interval = "5min", + *, + start_date: date | None = None, + end_date: date | None = None, ) -> list[CommodityIntradayPrice]: - """Get commodity intraday prices""" + """Get commodity intraday prices + + Additional keyword arguments (None preserves the existing request): + start_date: Start date passed to FMP; boundary semantics depend on the + endpoint. + end_date: End date passed to FMP; boundary semantics depend on the endpoint. + """ + optional_params = { + "start_date": start_date, + "end_date": end_date, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( await self.client.request_async( - COMMODITY_INTRADAY, symbol=symbol, interval=interval + COMMODITY_INTRADAY, symbol=symbol, interval=interval, **optional_params ), CommodityIntradayPrice, ) diff --git a/fmp_data/alternative/client.py b/fmp_data/alternative/client.py index cabb7914..4e5f6090 100644 --- a/fmp_data/alternative/client.py +++ b/fmp_data/alternative/client.py @@ -101,11 +101,31 @@ def get_crypto_historical( ) def get_crypto_intraday( - self, symbol: str, interval: Interval = "5min" + self, + symbol: str, + interval: Interval = "5min", + *, + start_date: date | None = None, + end_date: date | None = None, ) -> list[CryptoIntradayPrice]: - """Get cryptocurrency intraday prices""" + """Get cryptocurrency intraday prices + + Additional keyword arguments (None preserves the existing request): + start_date: Start date passed to FMP; boundary semantics depend on the + endpoint. + end_date: End date passed to FMP; boundary semantics depend on the endpoint. + """ + optional_params = { + "start_date": start_date, + "end_date": end_date, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - self.client.request(CRYPTO_INTRADAY, symbol=symbol, interval=interval), + self.client.request( + CRYPTO_INTRADAY, symbol=symbol, interval=interval, **optional_params + ), CryptoIntradayPrice, ) @@ -156,11 +176,31 @@ def get_forex_historical( ) def get_forex_intraday( - self, symbol: str, interval: Interval = "5min" + self, + symbol: str, + interval: Interval = "5min", + *, + start_date: date | None = None, + end_date: date | None = None, ) -> list[ForexIntradayPrice]: - """Get forex intraday prices""" + """Get forex intraday prices + + Additional keyword arguments (None preserves the existing request): + start_date: Start date passed to FMP; boundary semantics depend on the + endpoint. + end_date: End date passed to FMP; boundary semantics depend on the endpoint. + """ + optional_params = { + "start_date": start_date, + "end_date": end_date, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - self.client.request(FOREX_INTRADAY, symbol=symbol, interval=interval), + self.client.request( + FOREX_INTRADAY, symbol=symbol, interval=interval, **optional_params + ), ForexIntradayPrice, ) @@ -211,10 +251,30 @@ def get_commodity_historical( ) def get_commodity_intraday( - self, symbol: str, interval: Interval = "5min" + self, + symbol: str, + interval: Interval = "5min", + *, + start_date: date | None = None, + end_date: date | None = None, ) -> list[CommodityIntradayPrice]: - """Get commodity intraday prices""" + """Get commodity intraday prices + + Additional keyword arguments (None preserves the existing request): + start_date: Start date passed to FMP; boundary semantics depend on the + endpoint. + end_date: End date passed to FMP; boundary semantics depend on the endpoint. + """ + optional_params = { + "start_date": start_date, + "end_date": end_date, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - self.client.request(COMMODITY_INTRADAY, symbol=symbol, interval=interval), + self.client.request( + COMMODITY_INTRADAY, symbol=symbol, interval=interval, **optional_params + ), CommodityIntradayPrice, ) diff --git a/fmp_data/alternative/endpoints.py b/fmp_data/alternative/endpoints.py index ecd2853b..301ae3b0 100644 --- a/fmp_data/alternative/endpoints.py +++ b/fmp_data/alternative/endpoints.py @@ -170,7 +170,26 @@ description="Crypto pair symbol", ), ], - optional_params=[], + optional_params=[ + EndpointParam( + name="start_date", + alias="from", + location=ParamLocation.QUERY, + param_type=ParamType.DATE, + description=( + "Start date passed to FMP; boundary semantics depend on the endpoint." + ), + ), + EndpointParam( + name="end_date", + alias="to", + location=ParamLocation.QUERY, + param_type=ParamType.DATE, + description=( + "End date passed to FMP; boundary semantics depend on the endpoint." + ), + ), + ], response_model=CryptoIntradayPrice, example_queries=[ "Get Bitcoin minute-by-minute prices", @@ -320,7 +339,26 @@ description="Forex pair symbol", ), ], - optional_params=[], + optional_params=[ + EndpointParam( + name="start_date", + alias="from", + location=ParamLocation.QUERY, + param_type=ParamType.DATE, + description=( + "Start date passed to FMP; boundary semantics depend on the endpoint." + ), + ), + EndpointParam( + name="end_date", + alias="to", + location=ParamLocation.QUERY, + param_type=ParamType.DATE, + description=( + "End date passed to FMP; boundary semantics depend on the endpoint." + ), + ), + ], response_model=ForexIntradayPrice, example_queries=[ "Get minute-by-minute EURUSD data", @@ -476,7 +514,26 @@ description="Commodity symbol", ), ], - optional_params=[], + optional_params=[ + EndpointParam( + name="start_date", + alias="from", + location=ParamLocation.QUERY, + param_type=ParamType.DATE, + description=( + "Start date passed to FMP; boundary semantics depend on the endpoint." + ), + ), + EndpointParam( + name="end_date", + alias="to", + location=ParamLocation.QUERY, + param_type=ParamType.DATE, + description=( + "End date passed to FMP; boundary semantics depend on the endpoint." + ), + ), + ], response_model=CommodityIntradayPrice, example_queries=[ "Get minute-by-minute gold prices", diff --git a/fmp_data/alternative/mapping.py b/fmp_data/alternative/mapping.py index 44861084..8a0d082c 100644 --- a/fmp_data/alternative/mapping.py +++ b/fmp_data/alternative/mapping.py @@ -109,7 +109,7 @@ ], category=SemanticCategory.ALTERNATIVE_DATA, sub_category="Cryptocurrency", - parameter_hints={}, # No parameters needed + parameter_hints={}, response_hints={ "symbol": ResponseFieldInfo( description="Trading symbol for the cryptocurrency pair", @@ -203,7 +203,7 @@ ], category=SemanticCategory.ALTERNATIVE_DATA, sub_category="Cryptocurrency", - parameter_hints={}, # No parameters needed + parameter_hints={}, response_hints={ "price": ResponseFieldInfo( description="Current trading price", @@ -281,6 +281,8 @@ parameter_hints={ "symbol": SYMBOL_HINTS["crypto"], "interval": INTERVAL_HINT, + "start_date": DATE_HINTS["start_date"], + "end_date": DATE_HINTS["end_date"], }, response_hints={ "datetime": ResponseFieldInfo( @@ -503,6 +505,8 @@ parameter_hints={ "symbol": SYMBOL_HINTS["forex"], "interval": INTERVAL_HINT, + "start_date": DATE_HINTS["start_date"], + "end_date": DATE_HINTS["end_date"], }, response_hints={ "datetime": ResponseFieldInfo( @@ -707,6 +711,8 @@ parameter_hints={ "symbol": SYMBOL_HINTS["commodity"], "interval": INTERVAL_HINT, + "start_date": DATE_HINTS["start_date"], + "end_date": DATE_HINTS["end_date"], }, response_hints={ "datetime": ResponseFieldInfo( diff --git a/fmp_data/company/async_client.py b/fmp_data/company/async_client.py index 47f72ab7..ee727f4c 100644 --- a/fmp_data/company/async_client.py +++ b/fmp_data/company/async_client.py @@ -182,10 +182,27 @@ async def get_executives(self, symbol: str) -> list[CompanyExecutive]: CompanyExecutive, ) - async def get_employee_count(self, symbol: str) -> list[EmployeeCount]: - """Get company employee count history""" + async def get_employee_count( + self, + symbol: str, + *, + limit: int | None = None, + ) -> list[EmployeeCount]: + """Get company employee count history + + Additional keyword arguments (None preserves the existing request): + limit: Maximum number of results requested from FMP. + """ + optional_params = { + "limit": limit, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - await self.client.request_async(EMPLOYEE_COUNT, symbol=symbol), + await self.client.request_async( + EMPLOYEE_COUNT, symbol=symbol, **optional_params + ), EmployeeCount, ) @@ -269,6 +286,8 @@ async def get_intraday_prices( from_date: date | None = None, to_date: date | None = None, nonadjusted: bool | None = None, + *, + extended: bool | None = None, ) -> list[IntradayPrice]: """Get intraday price data @@ -278,7 +297,16 @@ async def get_intraday_prices( from_date: Start date (optional) to_date: End date (optional) nonadjusted: Use non-adjusted data (optional) + + Additional keyword arguments (None preserves the existing request): + extended: Include pre-market and after-hours prices. """ + optional_params = { + "extended": extended, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } start_date = _format_date(from_date) end_date = _format_date(to_date) return self._unwrap_list( @@ -289,6 +317,7 @@ async def get_intraday_prices( start_date=start_date, end_date=end_date, nonadjusted=nonadjusted, + **optional_params, ), IntradayPrice, ) @@ -380,10 +409,29 @@ async def get_geographic_revenue_segmentation( GeographicRevenueSegment, ) - async def get_symbol_changes(self) -> list[SymbolChange]: - """Get symbol change history""" + async def get_symbol_changes( + self, + *, + invalid: str | None = None, + limit: int | None = None, + ) -> list[SymbolChange]: + """Get symbol change history + + Additional keyword arguments (None preserves the existing request): + invalid: FMP invalid-symbol-change filter, supplied as a string (for example + false). + limit: Maximum number of results requested from FMP. + """ + optional_params = { + "invalid": invalid, + "limit": limit, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - await self.client.request_async(SYMBOL_CHANGES), SymbolChange + await self.client.request_async(SYMBOL_CHANGES, **optional_params), + SymbolChange, ) async def get_delisted_companies( @@ -415,11 +463,33 @@ async def get_market_cap(self, symbol: str) -> MarketCapitalization: return self._unwrap_single(result, MarketCapitalization) async def get_historical_market_cap( - self, symbol: str + self, + symbol: str, + *, + from_date: date | None = None, + to_date: date | None = None, + limit: int | None = None, ) -> list[MarketCapitalization]: - """Get historical market capitalization data""" + """Get historical market capitalization data + + Additional keyword arguments (None preserves the existing request): + from_date: Start date passed to FMP; boundary semantics depend on the + endpoint. + to_date: End date passed to FMP; boundary semantics depend on the endpoint. + limit: Maximum number of results requested from FMP. + """ + optional_params = { + "from_date": from_date, + "to_date": to_date, + "limit": limit, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - await self.client.request_async(HISTORICAL_MARKET_CAP, symbol=symbol), + await self.client.request_async( + HISTORICAL_MARKET_CAP, symbol=symbol, **optional_params + ), MarketCapitalization, ) @@ -722,7 +792,13 @@ async def get_dividends( await self.client.request_async(COMPANY_DIVIDENDS, **params), DividendEvent ) - async def get_earnings(self, symbol: str, limit: int = 20) -> list[EarningEvent]: + async def get_earnings( + self, + symbol: str, + limit: int = 20, + *, + include_report_times: bool | None = None, + ) -> list[EarningEvent]: """Get historical earnings reports for a specific company Args: @@ -731,10 +807,19 @@ async def get_earnings(self, symbol: str, limit: int = 20) -> list[EarningEvent] Returns: List of EarningEvent objects containing earnings history + + Additional keyword arguments (None preserves the existing request): + include_report_times: Include earnings report times when available. """ + optional_params = { + "include_report_times": include_report_times, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( await self.client.request_async( - COMPANY_EARNINGS, symbol=symbol, limit=limit + COMPANY_EARNINGS, symbol=symbol, limit=limit, **optional_params ), EarningEvent, ) diff --git a/fmp_data/company/client.py b/fmp_data/company/client.py index 385b3d2a..85ecc523 100644 --- a/fmp_data/company/client.py +++ b/fmp_data/company/client.py @@ -172,10 +172,26 @@ def get_executives(self, symbol: str) -> list[CompanyExecutive]: self.client.request(KEY_EXECUTIVES, symbol=symbol), CompanyExecutive ) - def get_employee_count(self, symbol: str) -> list[EmployeeCount]: - """Get company employee count history""" + def get_employee_count( + self, + symbol: str, + *, + limit: int | None = None, + ) -> list[EmployeeCount]: + """Get company employee count history + + Additional keyword arguments (None preserves the existing request): + limit: Maximum number of results requested from FMP. + """ + optional_params = { + "limit": limit, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - self.client.request(EMPLOYEE_COUNT, symbol=symbol), EmployeeCount + self.client.request(EMPLOYEE_COUNT, symbol=symbol, **optional_params), + EmployeeCount, ) def get_company_notes(self, symbol: str) -> list[CompanyNote]: @@ -259,6 +275,8 @@ def get_intraday_prices( from_date: date | None = None, to_date: date | None = None, nonadjusted: bool | None = None, + *, + extended: bool | None = None, ) -> list[IntradayPrice]: """Get intraday price data @@ -268,7 +286,16 @@ def get_intraday_prices( from_date: Start date (optional) to_date: End date (optional) nonadjusted: Use non-adjusted data (optional) + + Additional keyword arguments (None preserves the existing request): + extended: Include pre-market and after-hours prices. """ + optional_params = { + "extended": extended, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } start_date = _format_date(from_date) end_date = _format_date(to_date) return self._unwrap_list( @@ -279,6 +306,7 @@ def get_intraday_prices( start_date=start_date, end_date=end_date, nonadjusted=nonadjusted, + **optional_params, ), IntradayPrice, ) @@ -366,9 +394,29 @@ def get_geographic_revenue_segmentation( GeographicRevenueSegment, ) - def get_symbol_changes(self) -> list[SymbolChange]: - """Get symbol change history""" - return self._unwrap_list(self.client.request(SYMBOL_CHANGES), SymbolChange) + def get_symbol_changes( + self, + *, + invalid: str | None = None, + limit: int | None = None, + ) -> list[SymbolChange]: + """Get symbol change history + + Additional keyword arguments (None preserves the existing request): + invalid: FMP invalid-symbol-change filter, supplied as a string (for example + false). + limit: Maximum number of results requested from FMP. + """ + optional_params = { + "invalid": invalid, + "limit": limit, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } + return self._unwrap_list( + self.client.request(SYMBOL_CHANGES, **optional_params), SymbolChange + ) def get_delisted_companies( self, page: int = 0, limit: int = 100 @@ -398,10 +446,34 @@ def get_market_cap(self, symbol: str) -> MarketCapitalization: result = self.client.request(MARKET_CAP, symbol=symbol) return self._unwrap_single(result, MarketCapitalization) - def get_historical_market_cap(self, symbol: str) -> list[MarketCapitalization]: - """Get historical market capitalization data""" + def get_historical_market_cap( + self, + symbol: str, + *, + from_date: date | None = None, + to_date: date | None = None, + limit: int | None = None, + ) -> list[MarketCapitalization]: + """Get historical market capitalization data + + Additional keyword arguments (None preserves the existing request): + from_date: Start date passed to FMP; boundary semantics depend on the + endpoint. + to_date: End date passed to FMP; boundary semantics depend on the endpoint. + limit: Maximum number of results requested from FMP. + """ + optional_params = { + "from_date": from_date, + "to_date": to_date, + "limit": limit, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - self.client.request(HISTORICAL_MARKET_CAP, symbol=symbol), + self.client.request( + HISTORICAL_MARKET_CAP, symbol=symbol, **optional_params + ), MarketCapitalization, ) @@ -694,7 +766,13 @@ def get_dividends( self.client.request(COMPANY_DIVIDENDS, **params), DividendEvent ) - def get_earnings(self, symbol: str, limit: int = 20) -> list[EarningEvent]: + def get_earnings( + self, + symbol: str, + limit: int = 20, + *, + include_report_times: bool | None = None, + ) -> list[EarningEvent]: """Get historical earnings reports for a specific company Args: @@ -703,9 +781,20 @@ def get_earnings(self, symbol: str, limit: int = 20) -> list[EarningEvent]: Returns: List of EarningEvent objects containing earnings history + + Additional keyword arguments (None preserves the existing request): + include_report_times: Include earnings report times when available. """ + optional_params = { + "include_report_times": include_report_times, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - self.client.request(COMPANY_EARNINGS, symbol=symbol, limit=limit), + self.client.request( + COMPANY_EARNINGS, symbol=symbol, limit=limit, **optional_params + ), EarningEvent, ) diff --git a/fmp_data/company/endpoints.py b/fmp_data/company/endpoints.py index 3217b96a..bbbf4de7 100644 --- a/fmp_data/company/endpoints.py +++ b/fmp_data/company/endpoints.py @@ -320,6 +320,12 @@ param_type=ParamType.BOOLEAN, description="Use non-adjusted data", ), + EndpointParam( + name="extended", + location=ParamLocation.QUERY, + param_type=ParamType.BOOLEAN, + description="Include pre-market and after-hours prices.", + ), ], response_model=IntradayPrice, ) @@ -653,7 +659,23 @@ "historical data and understanding corporate actions." ), mandatory_params=[], - optional_params=[], + optional_params=[ + EndpointParam( + name="invalid", + location=ParamLocation.QUERY, + param_type=ParamType.STRING, + description=( + "FMP invalid-symbol-change filter, supplied as a string (for " + "example false)." + ), + ), + EndpointParam( + name="limit", + location=ParamLocation.QUERY, + param_type=ParamType.INTEGER, + description="Maximum number of results requested from FMP.", + ), + ], response_model=SymbolChange, example_queries=[ "Show recent stock symbol changes", @@ -722,7 +744,32 @@ description="Stock symbol (ticker)", ) ], - optional_params=[], + optional_params=[ + EndpointParam( + name="from_date", + alias="from", + location=ParamLocation.QUERY, + param_type=ParamType.DATE, + description=( + "Start date passed to FMP; boundary semantics depend on the endpoint." + ), + ), + EndpointParam( + name="to_date", + alias="to", + location=ParamLocation.QUERY, + param_type=ParamType.DATE, + description=( + "End date passed to FMP; boundary semantics depend on the endpoint." + ), + ), + EndpointParam( + name="limit", + location=ParamLocation.QUERY, + param_type=ParamType.INTEGER, + description="Maximum number of results requested from FMP.", + ), + ], response_model=MarketCapitalization, ) PRICE_TARGET: Endpoint[PriceTarget] = Endpoint( @@ -1280,6 +1327,13 @@ description="Number of earnings reports to return", default=20, ), + EndpointParam( + name="include_report_times", + alias="includeReportTimes", + location=ParamLocation.QUERY, + param_type=ParamType.BOOLEAN, + description="Include earnings report times when available.", + ), ], response_model=EarningEvent, example_queries=[ diff --git a/fmp_data/company/mapping.py b/fmp_data/company/mapping.py index 7d6efb5d..e610bb92 100644 --- a/fmp_data/company/mapping.py +++ b/fmp_data/company/mapping.py @@ -49,7 +49,12 @@ PERIOD_HINT, SYMBOL_HINT, ) -from fmp_data.lc.models import EndpointSemantics, ResponseFieldInfo, SemanticCategory +from fmp_data.lc.models import ( + EndpointSemantics, + ParameterHint, + ResponseFieldInfo, + SemanticCategory, +) from fmp_data.models import Endpoint # Company endpoints mapping @@ -521,7 +526,21 @@ "stock symbol history", ], category=SemanticCategory.COMPANY_INFO, - parameter_hints={}, # No parameters needed + parameter_hints={ + "invalid": ParameterHint( + natural_names=["invalid"], + extraction_patterns=[], + examples=["false"], + context_clues=[ + ( + "FMP invalid-symbol-change filter, supplied as a string (for " + "example false)." + ) + ], + required=False, + ), + "limit": LIMIT_HINT, + }, response_hints={ "old_symbol": ResponseFieldInfo( description="Previous trading symbol", @@ -1012,6 +1031,13 @@ "start_date": DATE_HINTS["start_date"], "end_date": DATE_HINTS["end_date"], "nonadjusted": NONADJUSTED_HINT, + "extended": ParameterHint( + natural_names=["extended"], + extraction_patterns=[], + examples=["false"], + context_clues=["Include pre-market and after-hours prices."], + required=False, + ), }, response_hints={ "datetime": ResponseFieldInfo( @@ -1133,6 +1159,13 @@ "start_date": DATE_HINTS["start_date"], "end_date": DATE_HINTS["end_date"], "nonadjusted": NONADJUSTED_HINT, + "extended": ParameterHint( + natural_names=["extended"], + extraction_patterns=[], + examples=["false"], + context_clues=["Include pre-market and after-hours prices."], + required=False, + ), }, response_hints={ "date": ResponseFieldInfo( @@ -1223,7 +1256,12 @@ ], category=SemanticCategory.COMPANY_INFO, sub_category="Historical Valuation", - parameter_hints={"symbol": SYMBOL_HINT}, + parameter_hints={ + "symbol": SYMBOL_HINT, + "from_date": DATE_HINTS["start_date"], + "to_date": DATE_HINTS["end_date"], + "limit": LIMIT_HINT, + }, response_hints={ "date": ResponseFieldInfo( description="Date of the market cap value", diff --git a/fmp_data/economics/async_client.py b/fmp_data/economics/async_client.py index 0ddbc409..2611a9e1 100644 --- a/fmp_data/economics/async_client.py +++ b/fmp_data/economics/async_client.py @@ -43,18 +43,52 @@ async def get_treasury_rates( ) async def get_economic_indicators( - self, indicator_name: EconomicIndicatorType | str + self, + indicator_name: EconomicIndicatorType | str, + *, + start_date: date | None = None, + end_date: date | None = None, ) -> list[EconomicIndicator]: - """Get economic indicator data""" + """Get economic indicator data + + Additional keyword arguments (None preserves the existing request): + start_date: Start date passed to FMP; boundary semantics depend on the + endpoint. + end_date: End date passed to FMP; boundary semantics depend on the endpoint. + """ + optional_params = { + "start_date": start_date, + "end_date": end_date, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - await self.client.request_async(ECONOMIC_INDICATORS, name=indicator_name), + await self.client.request_async( + ECONOMIC_INDICATORS, name=indicator_name, **optional_params + ), EconomicIndicator, ) async def get_economic_calendar( - self, start_date: date | None = None, end_date: date | None = None + self, + start_date: date | None = None, + end_date: date | None = None, + *, + country: str | None = None, ) -> list[EconomicEvent]: - """Get economic calendar events""" + """Get economic calendar events + + Additional keyword arguments (None preserves the existing request): + country: Country filter using the provider country code (for example US or + UK). + """ + optional_params = { + "country": country, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } params: dict[str, str] = {} if start_date: params["start_date"] = start_date.strftime("%Y-%m-%d") @@ -62,7 +96,10 @@ async def get_economic_calendar( params["end_date"] = end_date.strftime("%Y-%m-%d") return self._unwrap_list( - await self.client.request_async(ECONOMIC_CALENDAR, **params), EconomicEvent + await self.client.request_async( + ECONOMIC_CALENDAR, **params, **optional_params + ), + EconomicEvent, ) async def get_market_risk_premium(self) -> list[MarketRiskPremium]: diff --git a/fmp_data/economics/client.py b/fmp_data/economics/client.py index 9271f75d..108d4c9b 100644 --- a/fmp_data/economics/client.py +++ b/fmp_data/economics/client.py @@ -41,18 +41,52 @@ def get_treasury_rates( ) def get_economic_indicators( - self, indicator_name: EconomicIndicatorType | str + self, + indicator_name: EconomicIndicatorType | str, + *, + start_date: date | None = None, + end_date: date | None = None, ) -> list[EconomicIndicator]: - """Get economic indicator data""" + """Get economic indicator data + + Additional keyword arguments (None preserves the existing request): + start_date: Start date passed to FMP; boundary semantics depend on the + endpoint. + end_date: End date passed to FMP; boundary semantics depend on the endpoint. + """ + optional_params = { + "start_date": start_date, + "end_date": end_date, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - self.client.request(ECONOMIC_INDICATORS, name=indicator_name), + self.client.request( + ECONOMIC_INDICATORS, name=indicator_name, **optional_params + ), EconomicIndicator, ) def get_economic_calendar( - self, start_date: date | None = None, end_date: date | None = None + self, + start_date: date | None = None, + end_date: date | None = None, + *, + country: str | None = None, ) -> list[EconomicEvent]: - """Get economic calendar events""" + """Get economic calendar events + + Additional keyword arguments (None preserves the existing request): + country: Country filter using the provider country code (for example US or + UK). + """ + optional_params = { + "country": country, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } params: dict[str, str] = {} if start_date: params["start_date"] = start_date.strftime("%Y-%m-%d") @@ -60,7 +94,8 @@ def get_economic_calendar( params["end_date"] = end_date.strftime("%Y-%m-%d") return self._unwrap_list( - self.client.request(ECONOMIC_CALENDAR, **params), EconomicEvent + self.client.request(ECONOMIC_CALENDAR, **params, **optional_params), + EconomicEvent, ) def get_market_risk_premium(self) -> list[MarketRiskPremium]: diff --git a/fmp_data/economics/endpoints.py b/fmp_data/economics/endpoints.py index abc559ff..07b44e9b 100644 --- a/fmp_data/economics/endpoints.py +++ b/fmp_data/economics/endpoints.py @@ -76,7 +76,26 @@ valid_values=list(EconomicIndicatorType), ) ], - optional_params=[], + optional_params=[ + EndpointParam( + name="start_date", + alias="from", + location=ParamLocation.QUERY, + param_type=ParamType.DATE, + description=( + "Start date passed to FMP; boundary semantics depend on the endpoint." + ), + ), + EndpointParam( + name="end_date", + alias="to", + location=ParamLocation.QUERY, + param_type=ParamType.DATE, + description=( + "End date passed to FMP; boundary semantics depend on the endpoint." + ), + ), + ], response_model=EconomicIndicator, example_queries=[ "Get GDP growth rate", @@ -115,6 +134,14 @@ description="End date", alias="to", ), + EndpointParam( + name="country", + location=ParamLocation.QUERY, + param_type=ParamType.STRING, + description=( + "Country filter using the provider country code (for example US or UK)." + ), + ), ], response_model=EconomicEvent, example_queries=[ diff --git a/fmp_data/economics/mapping.py b/fmp_data/economics/mapping.py index adae1632..2d70b306 100644 --- a/fmp_data/economics/mapping.py +++ b/fmp_data/economics/mapping.py @@ -350,6 +350,8 @@ "data", ], ), + "start_date": DATE_HINTS["start_date"], + "end_date": DATE_HINTS["end_date"], }, response_hints={ "indicator_date": ResponseFieldInfo( @@ -449,6 +451,18 @@ "no later than", ], ), + "country": ParameterHint( + natural_names=["country"], + extraction_patterns=[], + examples=["US"], + context_clues=[ + ( + "Country filter using the provider country code " + "(for example US or UK)." + ) + ], + required=False, + ), }, response_hints={ "event": ResponseFieldInfo( @@ -582,7 +596,7 @@ ], category=SemanticCategory.ECONOMIC, sub_category="Risk Metrics", - parameter_hints={}, # No parameters needed + parameter_hints={}, response_hints={ "country": ResponseFieldInfo( description="Country name for risk premium data", diff --git a/fmp_data/fundamental/async_client.py b/fmp_data/fundamental/async_client.py index 7b01b389..985d8171 100644 --- a/fmp_data/fundamental/async_client.py +++ b/fmp_data/fundamental/async_client.py @@ -165,20 +165,188 @@ async def get_discounted_cash_flow(self, symbol: str) -> list[DCF]: DCF, ) - async def get_custom_discounted_cash_flow(self, symbol: str) -> list[CustomDCF]: - """Get advanced DCF analysis with detailed projections""" + async def get_custom_discounted_cash_flow( + self, + symbol: str, + *, + revenue_growth_pct: float | None = None, + ebitda_pct: float | None = None, + depreciation_and_amortization_pct: float | None = None, + cash_and_short_term_investments_pct: float | None = None, + receivables_pct: float | None = None, + inventories_pct: float | None = None, + payable_pct: float | None = None, + ebit_pct: float | None = None, + capital_expenditure_pct: float | None = None, + operating_cash_flow_pct: float | None = None, + selling_general_and_administrative_expenses_pct: float | None = None, + tax_rate: float | None = None, + long_term_growth_rate: float | None = None, + cost_of_debt: float | None = None, + cost_of_equity: float | None = None, + market_risk_premium: float | None = None, + beta: float | None = None, + risk_free_rate: float | None = None, + ) -> list[CustomDCF]: + """Get advanced DCF analysis with detailed projections + + Additional keyword arguments (None preserves the existing request): + revenue_growth_pct: Revenue growth pct assumption passed to FMP for the + custom DCF calculation. + ebitda_pct: Ebitda pct assumption passed to FMP for the custom DCF + calculation. + depreciation_and_amortization_pct: Depreciation and amortization pct + assumption passed to FMP for the custom DCF calculation. + cash_and_short_term_investments_pct: Cash and short term investments pct + assumption passed to FMP for the custom DCF calculation. + receivables_pct: Receivables pct assumption passed to FMP for the custom DCF + calculation. + inventories_pct: Inventories pct assumption passed to FMP for the custom DCF + calculation. + payable_pct: Payable pct assumption passed to FMP for the custom DCF + calculation. + ebit_pct: Ebit pct assumption passed to FMP for the custom DCF calculation. + capital_expenditure_pct: Capital expenditure pct assumption passed to FMP + for the custom DCF calculation. + operating_cash_flow_pct: Operating cash flow pct assumption passed to FMP + for the custom DCF calculation. + selling_general_and_administrative_expenses_pct: Selling general and + administrative expenses pct assumption passed to FMP for the custom DCF + calculation. + tax_rate: Tax rate assumption passed to FMP for the custom DCF calculation. + long_term_growth_rate: Long term growth rate assumption passed to FMP for + the custom DCF calculation. + cost_of_debt: Cost of debt assumption passed to FMP for the custom DCF + calculation. + cost_of_equity: Cost of equity assumption passed to FMP for the custom DCF + calculation. + market_risk_premium: Market risk premium assumption passed to FMP for the + custom DCF calculation. + beta: Beta assumption passed to FMP for the custom DCF calculation. + risk_free_rate: Risk free rate assumption passed to FMP for the custom DCF + calculation. + """ + optional_params = { + "revenue_growth_pct": revenue_growth_pct, + "ebitda_pct": ebitda_pct, + "depreciation_and_amortization_pct": depreciation_and_amortization_pct, + "cash_and_short_term_investments_pct": cash_and_short_term_investments_pct, + "receivables_pct": receivables_pct, + "inventories_pct": inventories_pct, + "payable_pct": payable_pct, + "ebit_pct": ebit_pct, + "capital_expenditure_pct": capital_expenditure_pct, + "operating_cash_flow_pct": operating_cash_flow_pct, + "selling_general_and_administrative_expenses_pct": ( + selling_general_and_administrative_expenses_pct + ), + "tax_rate": tax_rate, + "long_term_growth_rate": long_term_growth_rate, + "cost_of_debt": cost_of_debt, + "cost_of_equity": cost_of_equity, + "market_risk_premium": market_risk_premium, + "beta": beta, + "risk_free_rate": risk_free_rate, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( await self.client.request_async( - endpoints.CUSTOM_DISCOUNTED_CASH_FLOW, symbol=symbol + endpoints.CUSTOM_DISCOUNTED_CASH_FLOW, symbol=symbol, **optional_params ), CustomDCF, ) - async def get_custom_levered_dcf(self, symbol: str) -> list[CustomLeveredDCF]: - """Get levered DCF analysis using FCFE""" + async def get_custom_levered_dcf( + self, + symbol: str, + *, + revenue_growth_pct: float | None = None, + ebitda_pct: float | None = None, + depreciation_and_amortization_pct: float | None = None, + cash_and_short_term_investments_pct: float | None = None, + receivables_pct: float | None = None, + inventories_pct: float | None = None, + payable_pct: float | None = None, + ebit_pct: float | None = None, + capital_expenditure_pct: float | None = None, + operating_cash_flow_pct: float | None = None, + selling_general_and_administrative_expenses_pct: float | None = None, + tax_rate: float | None = None, + long_term_growth_rate: float | None = None, + cost_of_debt: float | None = None, + cost_of_equity: float | None = None, + market_risk_premium: float | None = None, + beta: float | None = None, + risk_free_rate: float | None = None, + ) -> list[CustomLeveredDCF]: + """Get levered DCF analysis using FCFE + + Additional keyword arguments (None preserves the existing request): + revenue_growth_pct: Revenue growth pct assumption passed to FMP for the + custom DCF calculation. + ebitda_pct: Ebitda pct assumption passed to FMP for the custom DCF + calculation. + depreciation_and_amortization_pct: Depreciation and amortization pct + assumption passed to FMP for the custom DCF calculation. + cash_and_short_term_investments_pct: Cash and short term investments pct + assumption passed to FMP for the custom DCF calculation. + receivables_pct: Receivables pct assumption passed to FMP for the custom DCF + calculation. + inventories_pct: Inventories pct assumption passed to FMP for the custom DCF + calculation. + payable_pct: Payable pct assumption passed to FMP for the custom DCF + calculation. + ebit_pct: Ebit pct assumption passed to FMP for the custom DCF calculation. + capital_expenditure_pct: Capital expenditure pct assumption passed to FMP + for the custom DCF calculation. + operating_cash_flow_pct: Operating cash flow pct assumption passed to FMP + for the custom DCF calculation. + selling_general_and_administrative_expenses_pct: Selling general and + administrative expenses pct assumption passed to FMP for the custom DCF + calculation. + tax_rate: Tax rate assumption passed to FMP for the custom DCF calculation. + long_term_growth_rate: Long term growth rate assumption passed to FMP for + the custom DCF calculation. + cost_of_debt: Cost of debt assumption passed to FMP for the custom DCF + calculation. + cost_of_equity: Cost of equity assumption passed to FMP for the custom DCF + calculation. + market_risk_premium: Market risk premium assumption passed to FMP for the + custom DCF calculation. + beta: Beta assumption passed to FMP for the custom DCF calculation. + risk_free_rate: Risk free rate assumption passed to FMP for the custom DCF + calculation. + """ + optional_params = { + "revenue_growth_pct": revenue_growth_pct, + "ebitda_pct": ebitda_pct, + "depreciation_and_amortization_pct": depreciation_and_amortization_pct, + "cash_and_short_term_investments_pct": cash_and_short_term_investments_pct, + "receivables_pct": receivables_pct, + "inventories_pct": inventories_pct, + "payable_pct": payable_pct, + "ebit_pct": ebit_pct, + "capital_expenditure_pct": capital_expenditure_pct, + "operating_cash_flow_pct": operating_cash_flow_pct, + "selling_general_and_administrative_expenses_pct": ( + selling_general_and_administrative_expenses_pct + ), + "tax_rate": tax_rate, + "long_term_growth_rate": long_term_growth_rate, + "cost_of_debt": cost_of_debt, + "cost_of_equity": cost_of_equity, + "market_risk_premium": market_risk_premium, + "beta": beta, + "risk_free_rate": risk_free_rate, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( await self.client.request_async( - endpoints.CUSTOM_LEVERED_DCF, symbol=symbol + endpoints.CUSTOM_LEVERED_DCF, symbol=symbol, **optional_params ), CustomLeveredDCF, ) diff --git a/fmp_data/fundamental/client.py b/fmp_data/fundamental/client.py index 972c7a0b..880ffd94 100644 --- a/fmp_data/fundamental/client.py +++ b/fmp_data/fundamental/client.py @@ -153,16 +153,188 @@ def get_discounted_cash_flow(self, symbol: str) -> list[DCF]: self.client.request(endpoints.DISCOUNTED_CASH_FLOW, symbol=symbol), DCF ) - def get_custom_discounted_cash_flow(self, symbol: str) -> list[CustomDCF]: - """Get advanced DCF analysis with detailed projections""" + def get_custom_discounted_cash_flow( + self, + symbol: str, + *, + revenue_growth_pct: float | None = None, + ebitda_pct: float | None = None, + depreciation_and_amortization_pct: float | None = None, + cash_and_short_term_investments_pct: float | None = None, + receivables_pct: float | None = None, + inventories_pct: float | None = None, + payable_pct: float | None = None, + ebit_pct: float | None = None, + capital_expenditure_pct: float | None = None, + operating_cash_flow_pct: float | None = None, + selling_general_and_administrative_expenses_pct: float | None = None, + tax_rate: float | None = None, + long_term_growth_rate: float | None = None, + cost_of_debt: float | None = None, + cost_of_equity: float | None = None, + market_risk_premium: float | None = None, + beta: float | None = None, + risk_free_rate: float | None = None, + ) -> list[CustomDCF]: + """Get advanced DCF analysis with detailed projections + + Additional keyword arguments (None preserves the existing request): + revenue_growth_pct: Revenue growth pct assumption passed to FMP for the + custom DCF calculation. + ebitda_pct: Ebitda pct assumption passed to FMP for the custom DCF + calculation. + depreciation_and_amortization_pct: Depreciation and amortization pct + assumption passed to FMP for the custom DCF calculation. + cash_and_short_term_investments_pct: Cash and short term investments pct + assumption passed to FMP for the custom DCF calculation. + receivables_pct: Receivables pct assumption passed to FMP for the custom DCF + calculation. + inventories_pct: Inventories pct assumption passed to FMP for the custom DCF + calculation. + payable_pct: Payable pct assumption passed to FMP for the custom DCF + calculation. + ebit_pct: Ebit pct assumption passed to FMP for the custom DCF calculation. + capital_expenditure_pct: Capital expenditure pct assumption passed to FMP + for the custom DCF calculation. + operating_cash_flow_pct: Operating cash flow pct assumption passed to FMP + for the custom DCF calculation. + selling_general_and_administrative_expenses_pct: Selling general and + administrative expenses pct assumption passed to FMP for the custom DCF + calculation. + tax_rate: Tax rate assumption passed to FMP for the custom DCF calculation. + long_term_growth_rate: Long term growth rate assumption passed to FMP for + the custom DCF calculation. + cost_of_debt: Cost of debt assumption passed to FMP for the custom DCF + calculation. + cost_of_equity: Cost of equity assumption passed to FMP for the custom DCF + calculation. + market_risk_premium: Market risk premium assumption passed to FMP for the + custom DCF calculation. + beta: Beta assumption passed to FMP for the custom DCF calculation. + risk_free_rate: Risk free rate assumption passed to FMP for the custom DCF + calculation. + """ + optional_params = { + "revenue_growth_pct": revenue_growth_pct, + "ebitda_pct": ebitda_pct, + "depreciation_and_amortization_pct": depreciation_and_amortization_pct, + "cash_and_short_term_investments_pct": cash_and_short_term_investments_pct, + "receivables_pct": receivables_pct, + "inventories_pct": inventories_pct, + "payable_pct": payable_pct, + "ebit_pct": ebit_pct, + "capital_expenditure_pct": capital_expenditure_pct, + "operating_cash_flow_pct": operating_cash_flow_pct, + "selling_general_and_administrative_expenses_pct": ( + selling_general_and_administrative_expenses_pct + ), + "tax_rate": tax_rate, + "long_term_growth_rate": long_term_growth_rate, + "cost_of_debt": cost_of_debt, + "cost_of_equity": cost_of_equity, + "market_risk_premium": market_risk_premium, + "beta": beta, + "risk_free_rate": risk_free_rate, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - self.client.request(endpoints.CUSTOM_DISCOUNTED_CASH_FLOW, symbol=symbol), + self.client.request( + endpoints.CUSTOM_DISCOUNTED_CASH_FLOW, symbol=symbol, **optional_params + ), CustomDCF, ) - def get_custom_levered_dcf(self, symbol: str) -> list[CustomLeveredDCF]: - """Get levered DCF analysis using FCFE""" + def get_custom_levered_dcf( + self, + symbol: str, + *, + revenue_growth_pct: float | None = None, + ebitda_pct: float | None = None, + depreciation_and_amortization_pct: float | None = None, + cash_and_short_term_investments_pct: float | None = None, + receivables_pct: float | None = None, + inventories_pct: float | None = None, + payable_pct: float | None = None, + ebit_pct: float | None = None, + capital_expenditure_pct: float | None = None, + operating_cash_flow_pct: float | None = None, + selling_general_and_administrative_expenses_pct: float | None = None, + tax_rate: float | None = None, + long_term_growth_rate: float | None = None, + cost_of_debt: float | None = None, + cost_of_equity: float | None = None, + market_risk_premium: float | None = None, + beta: float | None = None, + risk_free_rate: float | None = None, + ) -> list[CustomLeveredDCF]: + """Get levered DCF analysis using FCFE + + Additional keyword arguments (None preserves the existing request): + revenue_growth_pct: Revenue growth pct assumption passed to FMP for the + custom DCF calculation. + ebitda_pct: Ebitda pct assumption passed to FMP for the custom DCF + calculation. + depreciation_and_amortization_pct: Depreciation and amortization pct + assumption passed to FMP for the custom DCF calculation. + cash_and_short_term_investments_pct: Cash and short term investments pct + assumption passed to FMP for the custom DCF calculation. + receivables_pct: Receivables pct assumption passed to FMP for the custom DCF + calculation. + inventories_pct: Inventories pct assumption passed to FMP for the custom DCF + calculation. + payable_pct: Payable pct assumption passed to FMP for the custom DCF + calculation. + ebit_pct: Ebit pct assumption passed to FMP for the custom DCF calculation. + capital_expenditure_pct: Capital expenditure pct assumption passed to FMP + for the custom DCF calculation. + operating_cash_flow_pct: Operating cash flow pct assumption passed to FMP + for the custom DCF calculation. + selling_general_and_administrative_expenses_pct: Selling general and + administrative expenses pct assumption passed to FMP for the custom DCF + calculation. + tax_rate: Tax rate assumption passed to FMP for the custom DCF calculation. + long_term_growth_rate: Long term growth rate assumption passed to FMP for + the custom DCF calculation. + cost_of_debt: Cost of debt assumption passed to FMP for the custom DCF + calculation. + cost_of_equity: Cost of equity assumption passed to FMP for the custom DCF + calculation. + market_risk_premium: Market risk premium assumption passed to FMP for the + custom DCF calculation. + beta: Beta assumption passed to FMP for the custom DCF calculation. + risk_free_rate: Risk free rate assumption passed to FMP for the custom DCF + calculation. + """ + optional_params = { + "revenue_growth_pct": revenue_growth_pct, + "ebitda_pct": ebitda_pct, + "depreciation_and_amortization_pct": depreciation_and_amortization_pct, + "cash_and_short_term_investments_pct": cash_and_short_term_investments_pct, + "receivables_pct": receivables_pct, + "inventories_pct": inventories_pct, + "payable_pct": payable_pct, + "ebit_pct": ebit_pct, + "capital_expenditure_pct": capital_expenditure_pct, + "operating_cash_flow_pct": operating_cash_flow_pct, + "selling_general_and_administrative_expenses_pct": ( + selling_general_and_administrative_expenses_pct + ), + "tax_rate": tax_rate, + "long_term_growth_rate": long_term_growth_rate, + "cost_of_debt": cost_of_debt, + "cost_of_equity": cost_of_equity, + "market_risk_premium": market_risk_premium, + "beta": beta, + "risk_free_rate": risk_free_rate, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - self.client.request(endpoints.CUSTOM_LEVERED_DCF, symbol=symbol), + self.client.request( + endpoints.CUSTOM_LEVERED_DCF, symbol=symbol, **optional_params + ), CustomLeveredDCF, ) diff --git a/fmp_data/fundamental/endpoints.py b/fmp_data/fundamental/endpoints.py index b358643f..6225d782 100644 --- a/fmp_data/fundamental/endpoints.py +++ b/fmp_data/fundamental/endpoints.py @@ -452,7 +452,179 @@ description="Stock symbol (ticker)", ) ], - optional_params=[], + optional_params=[ + EndpointParam( + name="revenue_growth_pct", + alias="revenueGrowthPct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Revenue growth pct assumption passed to FMP for the custom DCF " + "calculation." + ), + ), + EndpointParam( + name="ebitda_pct", + alias="ebitdaPct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Ebitda pct assumption passed to FMP for the custom DCF calculation." + ), + ), + EndpointParam( + name="depreciation_and_amortization_pct", + alias="depreciationAndAmortizationPct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Depreciation and amortization pct assumption passed to FMP for " + "the custom DCF calculation." + ), + ), + EndpointParam( + name="cash_and_short_term_investments_pct", + alias="cashAndShortTermInvestmentsPct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Cash and short term investments pct assumption passed to FMP for " + "the custom DCF calculation." + ), + ), + EndpointParam( + name="receivables_pct", + alias="receivablesPct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Receivables pct assumption passed to FMP for the custom DCF " + "calculation." + ), + ), + EndpointParam( + name="inventories_pct", + alias="inventoriesPct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Inventories pct assumption passed to FMP for the custom DCF " + "calculation." + ), + ), + EndpointParam( + name="payable_pct", + alias="payablePct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Payable pct assumption passed to FMP for the custom DCF calculation." + ), + ), + EndpointParam( + name="ebit_pct", + alias="ebitPct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Ebit pct assumption passed to FMP for the custom DCF calculation." + ), + ), + EndpointParam( + name="capital_expenditure_pct", + alias="capitalExpenditurePct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Capital expenditure pct assumption passed to FMP for the custom " + "DCF calculation." + ), + ), + EndpointParam( + name="operating_cash_flow_pct", + alias="operatingCashFlowPct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Operating cash flow pct assumption passed to FMP for the custom " + "DCF calculation." + ), + ), + EndpointParam( + name="selling_general_and_administrative_expenses_pct", + alias="sellingGeneralAndAdministrativeExpensesPct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Selling general and administrative expenses pct assumption passed " + "to FMP for the custom DCF calculation." + ), + ), + EndpointParam( + name="tax_rate", + alias="taxRate", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Tax rate assumption passed to FMP for the custom DCF calculation." + ), + ), + EndpointParam( + name="long_term_growth_rate", + alias="longTermGrowthRate", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Long term growth rate assumption passed to FMP for the custom DCF " + "calculation." + ), + ), + EndpointParam( + name="cost_of_debt", + alias="costOfDebt", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Cost of debt assumption passed to FMP for the custom DCF calculation." + ), + ), + EndpointParam( + name="cost_of_equity", + alias="costOfEquity", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Cost of equity assumption passed to FMP for the custom DCF " + "calculation." + ), + ), + EndpointParam( + name="market_risk_premium", + alias="marketRiskPremium", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Market risk premium assumption passed to FMP for the custom DCF " + "calculation." + ), + ), + EndpointParam( + name="beta", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description="Beta assumption passed to FMP for the custom DCF calculation.", + ), + EndpointParam( + name="risk_free_rate", + alias="riskFreeRate", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Risk free rate assumption passed to FMP for the custom DCF " + "calculation." + ), + ), + ], response_model=CustomDCF, example_queries=[ "Get detailed DCF for AAPL", @@ -479,7 +651,179 @@ description="Stock symbol (ticker)", ) ], - optional_params=[], + optional_params=[ + EndpointParam( + name="revenue_growth_pct", + alias="revenueGrowthPct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Revenue growth pct assumption passed to FMP for the custom DCF " + "calculation." + ), + ), + EndpointParam( + name="ebitda_pct", + alias="ebitdaPct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Ebitda pct assumption passed to FMP for the custom DCF calculation." + ), + ), + EndpointParam( + name="depreciation_and_amortization_pct", + alias="depreciationAndAmortizationPct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Depreciation and amortization pct assumption passed to FMP for " + "the custom DCF calculation." + ), + ), + EndpointParam( + name="cash_and_short_term_investments_pct", + alias="cashAndShortTermInvestmentsPct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Cash and short term investments pct assumption passed to FMP for " + "the custom DCF calculation." + ), + ), + EndpointParam( + name="receivables_pct", + alias="receivablesPct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Receivables pct assumption passed to FMP for the custom DCF " + "calculation." + ), + ), + EndpointParam( + name="inventories_pct", + alias="inventoriesPct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Inventories pct assumption passed to FMP for the custom DCF " + "calculation." + ), + ), + EndpointParam( + name="payable_pct", + alias="payablePct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Payable pct assumption passed to FMP for the custom DCF calculation." + ), + ), + EndpointParam( + name="ebit_pct", + alias="ebitPct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Ebit pct assumption passed to FMP for the custom DCF calculation." + ), + ), + EndpointParam( + name="capital_expenditure_pct", + alias="capitalExpenditurePct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Capital expenditure pct assumption passed to FMP for the custom " + "DCF calculation." + ), + ), + EndpointParam( + name="operating_cash_flow_pct", + alias="operatingCashFlowPct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Operating cash flow pct assumption passed to FMP for the custom " + "DCF calculation." + ), + ), + EndpointParam( + name="selling_general_and_administrative_expenses_pct", + alias="sellingGeneralAndAdministrativeExpensesPct", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Selling general and administrative expenses pct assumption passed " + "to FMP for the custom DCF calculation." + ), + ), + EndpointParam( + name="tax_rate", + alias="taxRate", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Tax rate assumption passed to FMP for the custom DCF calculation." + ), + ), + EndpointParam( + name="long_term_growth_rate", + alias="longTermGrowthRate", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Long term growth rate assumption passed to FMP for the custom DCF " + "calculation." + ), + ), + EndpointParam( + name="cost_of_debt", + alias="costOfDebt", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Cost of debt assumption passed to FMP for the custom DCF calculation." + ), + ), + EndpointParam( + name="cost_of_equity", + alias="costOfEquity", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Cost of equity assumption passed to FMP for the custom DCF " + "calculation." + ), + ), + EndpointParam( + name="market_risk_premium", + alias="marketRiskPremium", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Market risk premium assumption passed to FMP for the custom DCF " + "calculation." + ), + ), + EndpointParam( + name="beta", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description="Beta assumption passed to FMP for the custom DCF calculation.", + ), + EndpointParam( + name="risk_free_rate", + alias="riskFreeRate", + location=ParamLocation.QUERY, + param_type=ParamType.FLOAT, + description=( + "Risk free rate assumption passed to FMP for the custom DCF " + "calculation." + ), + ), + ], response_model=CustomLeveredDCF, example_queries=[ "Calculate levered DCF for AAPL", diff --git a/fmp_data/fundamental/mapping.py b/fmp_data/fundamental/mapping.py index 401afcb2..c910b928 100644 --- a/fmp_data/fundamental/mapping.py +++ b/fmp_data/fundamental/mapping.py @@ -1,5 +1,4 @@ # fmp_data/fundamental/mapping.py - from typing import Any from fmp_data.fundamental.endpoints import ( @@ -19,6 +18,7 @@ OWNER_EARNINGS, ) from fmp_data.lc.hints import ( + DCF_PARAMETER_HINTS, LIMIT_HINT, PAGE_HINT, PERIOD_WITH_FISCAL_HINT, @@ -882,7 +882,10 @@ ], category=SemanticCategory.FUNDAMENTAL_ANALYSIS, sub_category="Valuation", - parameter_hints={"symbol": SYMBOL_HINT}, + parameter_hints={ + "symbol": SYMBOL_HINT, + **DCF_PARAMETER_HINTS, + }, response_hints={ "fcf0": ResponseFieldInfo( description="Current year free cash flow", @@ -947,7 +950,10 @@ ], category=SemanticCategory.FUNDAMENTAL_ANALYSIS, sub_category="Valuation", - parameter_hints={"symbol": SYMBOL_HINT}, + parameter_hints={ + "symbol": SYMBOL_HINT, + **DCF_PARAMETER_HINTS, + }, response_hints={ "fcfe0": ResponseFieldInfo( description="Current year free cash flow to equity", diff --git a/fmp_data/institutional/async_client.py b/fmp_data/institutional/async_client.py index 8c9e83e4..da9dc8fa 100644 --- a/fmp_data/institutional/async_client.py +++ b/fmp_data/institutional/async_client.py @@ -288,10 +288,27 @@ async def search_cik_by_name(self, name: str, page: int = 0) -> list[CIKMapping] and name_upper in item.reporting_name.upper() ] - async def get_beneficial_ownership(self, symbol: str) -> list[BeneficialOwnership]: - """Get beneficial ownership data for a symbol""" + async def get_beneficial_ownership( + self, + symbol: str, + *, + limit: int | None = None, + ) -> list[BeneficialOwnership]: + """Get beneficial ownership data for a symbol + + Additional keyword arguments (None preserves the existing request): + limit: Maximum number of results requested from FMP. + """ + optional_params = { + "limit": limit, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - await self.client.request_async(BENEFICIAL_OWNERSHIP, symbol=symbol), + await self.client.request_async( + BENEFICIAL_OWNERSHIP, symbol=symbol, **optional_params + ), BeneficialOwnership, ) diff --git a/fmp_data/institutional/client.py b/fmp_data/institutional/client.py index 51f32284..1c52b6b7 100644 --- a/fmp_data/institutional/client.py +++ b/fmp_data/institutional/client.py @@ -306,10 +306,25 @@ def search_cik_by_name(self, name: str, page: int = 0) -> list[CIKMapping]: and name_upper in item.reporting_name.upper() ] - def get_beneficial_ownership(self, symbol: str) -> list[BeneficialOwnership]: - """Get beneficial ownership data for a symbol""" + def get_beneficial_ownership( + self, + symbol: str, + *, + limit: int | None = None, + ) -> list[BeneficialOwnership]: + """Get beneficial ownership data for a symbol + + Additional keyword arguments (None preserves the existing request): + limit: Maximum number of results requested from FMP. + """ + optional_params = { + "limit": limit, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - self.client.request(BENEFICIAL_OWNERSHIP, symbol=symbol), + self.client.request(BENEFICIAL_OWNERSHIP, symbol=symbol, **optional_params), BeneficialOwnership, ) diff --git a/fmp_data/institutional/endpoints.py b/fmp_data/institutional/endpoints.py index f35779ac..e807cca9 100644 --- a/fmp_data/institutional/endpoints.py +++ b/fmp_data/institutional/endpoints.py @@ -295,7 +295,14 @@ description="Stock symbol", ) ], - optional_params=[], + optional_params=[ + EndpointParam( + name="limit", + location=ParamLocation.QUERY, + param_type=ParamType.INTEGER, + description="Maximum number of results requested from FMP.", + ), + ], response_model=BeneficialOwnership, ) diff --git a/fmp_data/institutional/mapping.py b/fmp_data/institutional/mapping.py index 6fb437cd..f15db0c3 100644 --- a/fmp_data/institutional/mapping.py +++ b/fmp_data/institutional/mapping.py @@ -344,7 +344,7 @@ ], category=SemanticCategory.INSTITUTIONAL, sub_category="Insider Activity", - parameter_hints={}, # No parameters needed + parameter_hints={}, response_hints={ "code": ResponseFieldInfo( description="Transaction type code", @@ -512,7 +512,10 @@ ], category=SemanticCategory.INSTITUTIONAL, sub_category="Ownership Analysis", - parameter_hints={"symbol": SYMBOL_HINT}, + parameter_hints={ + "symbol": SYMBOL_HINT, + "limit": LIMIT_HINT, + }, response_hints={ "amount_beneficially_owned": ResponseFieldInfo( description="Number of shares beneficially owned", diff --git a/fmp_data/intelligence/async_client.py b/fmp_data/intelligence/async_client.py index 4121e8e3..53612c0c 100644 --- a/fmp_data/intelligence/async_client.py +++ b/fmp_data/intelligence/async_client.py @@ -138,6 +138,8 @@ async def get_earnings_calendar( start_date: date | None = None, end_date: date | None = None, include_report_times: bool | None = None, + *, + page: int | None = None, ) -> list[EarningEvent]: """Get earnings calendar @@ -151,12 +153,24 @@ async def get_earnings_calendar( Returns: list[EarningEvent]: Earnings events in the requested window + + Additional keyword arguments (None preserves the existing request): + page: Zero-based page number; pagination is controlled by the caller. """ + optional_params = { + "page": page, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } params: dict[str, Any] = self._build_date_params(start_date, end_date) if include_report_times is not None: params["include_report_times"] = include_report_times return self._unwrap_list( - await self.client.request_async(EARNINGS_CALENDAR, **params), EarningEvent + await self.client.request_async( + EARNINGS_CALENDAR, **params, **optional_params + ), + EarningEvent, ) async def get_historical_earnings( @@ -226,21 +240,54 @@ async def get_earnings_surprises(self, symbol: str) -> list[EarningSurprise]: return [] async def get_dividends_calendar( - self, start_date: date | None = None, end_date: date | None = None + self, + start_date: date | None = None, + end_date: date | None = None, + *, + page: int | None = None, ) -> list[DividendEvent]: - """Get dividends calendar""" + """Get dividends calendar + + Additional keyword arguments (None preserves the existing request): + page: Zero-based page number; pagination is controlled by the caller. + """ + optional_params = { + "page": page, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } params = self._build_date_params(start_date, end_date) return self._unwrap_list( - await self.client.request_async(DIVIDENDS_CALENDAR, **params), DividendEvent + await self.client.request_async( + DIVIDENDS_CALENDAR, **params, **optional_params + ), + DividendEvent, ) async def get_stock_splits_calendar( - self, start_date: date | None = None, end_date: date | None = None + self, + start_date: date | None = None, + end_date: date | None = None, + *, + page: int | None = None, ) -> list[StockSplitEvent]: - """Get stock splits calendar""" + """Get stock splits calendar + + Additional keyword arguments (None preserves the existing request): + page: Zero-based page number; pagination is controlled by the caller. + """ + optional_params = { + "page": page, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } params = self._build_date_params(start_date, end_date) return self._unwrap_list( - await self.client.request_async(STOCK_SPLITS_CALENDAR, **params), + await self.client.request_async( + STOCK_SPLITS_CALENDAR, **params, **optional_params + ), StockSplitEvent, ) @@ -547,10 +594,25 @@ async def get_esg_ratings(self, symbol: str) -> ESGRating | None: result = await self.client.request_async(ESG_RATINGS, symbol=symbol) return self._unwrap_single(result, ESGRating, allow_none=True) - async def get_esg_benchmark(self) -> list[ESGBenchmark]: - """Get ESG benchmark data""" + async def get_esg_benchmark( + self, + *, + year: int | None = None, + ) -> list[ESGBenchmark]: + """Get ESG benchmark data + + Additional keyword arguments (None preserves the existing request): + year: Benchmark reporting year. + """ + optional_params = { + "year": year, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - await self.client.request_async(ESG_BENCHMARK), ESGBenchmark + await self.client.request_async(ESG_BENCHMARK, **optional_params), + ESGBenchmark, ) # Government trading methods @@ -563,10 +625,31 @@ async def get_senate_latest( SenateTrade, ) - async def get_senate_trading(self, symbol: str) -> list[SenateTrade]: - """Get Senate trading data""" + async def get_senate_trading( + self, + symbol: str, + *, + page: int | None = None, + limit: int | None = None, + ) -> list[SenateTrade]: + """Get Senate trading data + + Additional keyword arguments (None preserves the existing request): + page: Zero-based page number; pagination is controlled by the caller. + limit: Maximum number of results requested from FMP. + """ + optional_params = { + "page": page, + "limit": limit, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - await self.client.request_async(SENATE_TRADING, symbol=symbol), SenateTrade + await self.client.request_async( + SENATE_TRADING, symbol=symbol, **optional_params + ), + SenateTrade, ) async def get_senate_trades_by_name(self, name: str) -> list[SenateTrade]: @@ -613,10 +696,30 @@ async def get_house_latest( HouseDisclosure, ) - async def get_house_disclosure(self, symbol: str) -> list[HouseDisclosure]: - """Get House disclosure data""" + async def get_house_disclosure( + self, + symbol: str, + *, + page: int | None = None, + limit: int | None = None, + ) -> list[HouseDisclosure]: + """Get House disclosure data + + Additional keyword arguments (None preserves the existing request): + page: Zero-based page number; pagination is controlled by the caller. + limit: Maximum number of results requested from FMP. + """ + optional_params = { + "page": page, + "limit": limit, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - await self.client.request_async(HOUSE_DISCLOSURE, symbol=symbol), + await self.client.request_async( + HOUSE_DISCLOSURE, symbol=symbol, **optional_params + ), HouseDisclosure, ) diff --git a/fmp_data/intelligence/client.py b/fmp_data/intelligence/client.py index 85d89d00..fdbfd2e8 100644 --- a/fmp_data/intelligence/client.py +++ b/fmp_data/intelligence/client.py @@ -136,6 +136,8 @@ def get_earnings_calendar( start_date: date | None = None, end_date: date | None = None, include_report_times: bool | None = None, + *, + page: int | None = None, ) -> list[EarningEvent]: """Get earnings calendar @@ -149,12 +151,22 @@ def get_earnings_calendar( Returns: list[EarningEvent]: Earnings events in the requested window + + Additional keyword arguments (None preserves the existing request): + page: Zero-based page number; pagination is controlled by the caller. """ + optional_params = { + "page": page, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } params: dict[str, Any] = self._build_date_params(start_date, end_date) if include_report_times is not None: params["include_report_times"] = include_report_times return self._unwrap_list( - self.client.request(EARNINGS_CALENDAR, **params), EarningEvent + self.client.request(EARNINGS_CALENDAR, **params, **optional_params), + EarningEvent, ) def get_historical_earnings( @@ -224,21 +236,51 @@ def get_earnings_surprises(self, symbol: str) -> list[EarningSurprise]: return [] def get_dividends_calendar( - self, start_date: date | None = None, end_date: date | None = None + self, + start_date: date | None = None, + end_date: date | None = None, + *, + page: int | None = None, ) -> list[DividendEvent]: - """Get dividends calendar""" + """Get dividends calendar + + Additional keyword arguments (None preserves the existing request): + page: Zero-based page number; pagination is controlled by the caller. + """ + optional_params = { + "page": page, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } params = self._build_date_params(start_date, end_date) return self._unwrap_list( - self.client.request(DIVIDENDS_CALENDAR, **params), DividendEvent + self.client.request(DIVIDENDS_CALENDAR, **params, **optional_params), + DividendEvent, ) def get_stock_splits_calendar( - self, start_date: date | None = None, end_date: date | None = None + self, + start_date: date | None = None, + end_date: date | None = None, + *, + page: int | None = None, ) -> list[StockSplitEvent]: - """Get stock splits calendar""" + """Get stock splits calendar + + Additional keyword arguments (None preserves the existing request): + page: Zero-based page number; pagination is controlled by the caller. + """ + optional_params = { + "page": page, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } params = self._build_date_params(start_date, end_date) return self._unwrap_list( - self.client.request(STOCK_SPLITS_CALENDAR, **params), StockSplitEvent + self.client.request(STOCK_SPLITS_CALENDAR, **params, **optional_params), + StockSplitEvent, ) def get_ipo_calendar( @@ -531,9 +573,25 @@ def get_esg_ratings(self, symbol: str) -> ESGRating | None: result = self.client.request(ESG_RATINGS, symbol=symbol) return self._unwrap_single(result, ESGRating, allow_none=True) - def get_esg_benchmark(self) -> list[ESGBenchmark]: - """Get ESG benchmark data""" - return self._unwrap_list(self.client.request(ESG_BENCHMARK), ESGBenchmark) + def get_esg_benchmark( + self, + *, + year: int | None = None, + ) -> list[ESGBenchmark]: + """Get ESG benchmark data + + Additional keyword arguments (None preserves the existing request): + year: Benchmark reporting year. + """ + optional_params = { + "year": year, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } + return self._unwrap_list( + self.client.request(ESG_BENCHMARK, **optional_params), ESGBenchmark + ) # Government trading methods def get_senate_latest(self, page: int = 0, limit: int = 100) -> list[SenateTrade]: @@ -542,10 +600,29 @@ def get_senate_latest(self, page: int = 0, limit: int = 100) -> list[SenateTrade self.client.request(SENATE_LATEST, page=page, limit=limit), SenateTrade ) - def get_senate_trading(self, symbol: str) -> list[SenateTrade]: - """Get Senate trading data""" + def get_senate_trading( + self, + symbol: str, + *, + page: int | None = None, + limit: int | None = None, + ) -> list[SenateTrade]: + """Get Senate trading data + + Additional keyword arguments (None preserves the existing request): + page: Zero-based page number; pagination is controlled by the caller. + limit: Maximum number of results requested from FMP. + """ + optional_params = { + "page": page, + "limit": limit, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - self.client.request(SENATE_TRADING, symbol=symbol), SenateTrade + self.client.request(SENATE_TRADING, symbol=symbol, **optional_params), + SenateTrade, ) def get_senate_trades_by_name(self, name: str) -> list[SenateTrade]: @@ -590,10 +667,29 @@ def get_house_latest( self.client.request(HOUSE_LATEST, page=page, limit=limit), HouseDisclosure ) - def get_house_disclosure(self, symbol: str) -> list[HouseDisclosure]: - """Get House disclosure data""" + def get_house_disclosure( + self, + symbol: str, + *, + page: int | None = None, + limit: int | None = None, + ) -> list[HouseDisclosure]: + """Get House disclosure data + + Additional keyword arguments (None preserves the existing request): + page: Zero-based page number; pagination is controlled by the caller. + limit: Maximum number of results requested from FMP. + """ + optional_params = { + "page": page, + "limit": limit, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - self.client.request(HOUSE_DISCLOSURE, symbol=symbol), HouseDisclosure + self.client.request(HOUSE_DISCLOSURE, symbol=symbol, **optional_params), + HouseDisclosure, ) def get_house_trades_by_name(self, name: str) -> list[HouseDisclosure]: diff --git a/fmp_data/intelligence/endpoints.py b/fmp_data/intelligence/endpoints.py index 01f357c1..2c0f9d0e 100644 --- a/fmp_data/intelligence/endpoints.py +++ b/fmp_data/intelligence/endpoints.py @@ -80,6 +80,14 @@ ), alias="includeReportTimes", ), + EndpointParam( + name="page", + location=ParamLocation.QUERY, + param_type=ParamType.INTEGER, + description=( + "Zero-based page number; pagination is controlled by the caller." + ), + ), ], response_model=EarningEvent, ) @@ -189,6 +197,14 @@ description="End date", alias="to", ), + EndpointParam( + name="page", + location=ParamLocation.QUERY, + param_type=ParamType.INTEGER, + description=( + "Zero-based page number; pagination is controlled by the caller." + ), + ), ], response_model=DividendEvent, ) @@ -216,6 +232,14 @@ description="End date", alias="to", ), + EndpointParam( + name="page", + location=ParamLocation.QUERY, + param_type=ParamType.INTEGER, + description=( + "Zero-based page number; pagination is controlled by the caller." + ), + ), ], response_model=StockSplitEvent, ) @@ -782,7 +806,14 @@ version=APIVersion.STABLE, description="Get ESG benchmark data", mandatory_params=[], - optional_params=[], + optional_params=[ + EndpointParam( + name="year", + location=ParamLocation.QUERY, + param_type=ParamType.INTEGER, + description="Benchmark reporting year.", + ), + ], response_model=ESGBenchmark, ) @@ -825,7 +856,22 @@ description="Stock symbol", ) ], - optional_params=[], + optional_params=[ + EndpointParam( + name="page", + location=ParamLocation.QUERY, + param_type=ParamType.INTEGER, + description=( + "Zero-based page number; pagination is controlled by the caller." + ), + ), + EndpointParam( + name="limit", + location=ParamLocation.QUERY, + param_type=ParamType.INTEGER, + description="Maximum number of results requested from FMP.", + ), + ], response_model=SenateTrade, ) @@ -939,7 +985,22 @@ description="Stock symbol", ) ], - optional_params=[], + optional_params=[ + EndpointParam( + name="page", + location=ParamLocation.QUERY, + param_type=ParamType.INTEGER, + description=( + "Zero-based page number; pagination is controlled by the caller." + ), + ), + EndpointParam( + name="limit", + location=ParamLocation.QUERY, + param_type=ParamType.INTEGER, + description="Maximum number of results requested from FMP.", + ), + ], response_model=HouseDisclosure, ) diff --git a/fmp_data/intelligence/mapping.py b/fmp_data/intelligence/mapping.py index c34468b3..b8aade1b 100644 --- a/fmp_data/intelligence/mapping.py +++ b/fmp_data/intelligence/mapping.py @@ -1,5 +1,4 @@ # fmp_data/intelligence/mapping.py - from typing import Any from fmp_data.intelligence.endpoints import ( @@ -62,6 +61,7 @@ PAGE_HINT, SENATE_ID_HINT, SYMBOL_HINT, + YEAR_HINT, ) from fmp_data.lc.models import ( EndpointSemantics, @@ -275,6 +275,7 @@ "start_date": DATE_HINTS["start_date"], "end_date": DATE_HINTS["end_date"], "include_report_times": INCLUDE_REPORT_TIMES_HINT, + "page": PAGE_HINT, }, response_hints={ "date": ResponseFieldInfo( @@ -841,7 +842,11 @@ ], category=SemanticCategory.INTELLIGENCE, sub_category="Government Trading", - parameter_hints={"symbol": SYMBOL_HINT}, + parameter_hints={ + "symbol": SYMBOL_HINT, + "page": PAGE_HINT, + "limit": LIMIT_HINT, + }, response_hints={ "first_name": ResponseFieldInfo( description="Representative first name", @@ -1488,6 +1493,7 @@ parameter_hints={ "start_date": DATE_HINTS["start_date"], "end_date": DATE_HINTS["end_date"], + "page": PAGE_HINT, }, response_hints={ "date": ResponseFieldInfo( @@ -1535,6 +1541,7 @@ parameter_hints={ "start_date": DATE_HINTS["start_date"], "end_date": DATE_HINTS["end_date"], + "page": PAGE_HINT, }, response_hints={ "date": ResponseFieldInfo( @@ -1666,7 +1673,9 @@ ], category=SemanticCategory.INTELLIGENCE, sub_category="ESG", - parameter_hints={}, + parameter_hints={ + "year": YEAR_HINT, + }, response_hints={ "sector": ResponseFieldInfo( description="Industry sector name", @@ -1708,7 +1717,11 @@ ], category=SemanticCategory.INTELLIGENCE, sub_category="Government Trading", - parameter_hints={"symbol": SYMBOL_HINT}, + parameter_hints={ + "symbol": SYMBOL_HINT, + "page": PAGE_HINT, + "limit": LIMIT_HINT, + }, response_hints={ "transaction_date": ResponseFieldInfo( description="Date of the trade", diff --git a/fmp_data/lc/hints.py b/fmp_data/lc/hints.py index b3a1c316..b6c17748 100644 --- a/fmp_data/lc/hints.py +++ b/fmp_data/lc/hints.py @@ -513,3 +513,45 @@ "from": DATE_HINTS["start_date"], "to": DATE_HINTS["end_date"], } + + +# Optional controls shared by the audited client methods (Refs #396). +TIMESTAMP_HINT = ParameterHint( + natural_names=["timestamp", "Unix timestamp", "market open at"], + extraction_patterns=[], + examples=["1789138800"], + context_clues=["at", "market hours", "Unix seconds"], + required=False, +) + +DCF_PARAMETER_HINTS = { + name: ParameterHint( + natural_names=[label], + extraction_patterns=[], + examples=["0", "5.5"], + context_clues=["DCF", "valuation assumption", label], + required=False, + ) + for name, label in { + "revenue_growth_pct": "revenue growth pct", + "ebitda_pct": "ebitda pct", + "depreciation_and_amortization_pct": "depreciation and amortization pct", + "cash_and_short_term_investments_pct": "cash and short term investments pct", + "receivables_pct": "receivables pct", + "inventories_pct": "inventories pct", + "payable_pct": "payable pct", + "ebit_pct": "ebit pct", + "capital_expenditure_pct": "capital expenditure pct", + "operating_cash_flow_pct": "operating cash flow pct", + "selling_general_and_administrative_expenses_pct": ( + "selling general and administrative expenses pct" + ), + "tax_rate": "tax rate", + "long_term_growth_rate": "long term growth rate", + "cost_of_debt": "cost of debt", + "cost_of_equity": "cost of equity", + "market_risk_premium": "market risk premium", + "beta": "beta", + "risk_free_rate": "risk free rate", + }.items() +} diff --git a/fmp_data/market/async_client.py b/fmp_data/market/async_client.py index 1bf1ea96..ae999796 100644 --- a/fmp_data/market/async_client.py +++ b/fmp_data/market/async_client.py @@ -95,7 +95,10 @@ async def search_symbol( ) async def search_exchange_variants(self, query: str) -> list[CompanySearchResult]: - """Search for exchange trading variants of a company""" + """Search exchange variants by ticker (for example ``query="MSFT"``). + + The public ``query`` argument is sent as FMP's ``symbol`` parameter. + """ return self._unwrap_list( await self.client.request_async(SEARCH_EXCHANGE_VARIANTS, query=query), CompanySearchResult, @@ -156,7 +159,12 @@ async def get_available_indexes(self) -> list[AvailableIndex]: await self.client.request_async(AVAILABLE_INDEXES), AvailableIndex ) - async def search_by_cik(self, query: str) -> list[CIKResult]: + async def search_by_cik( + self, + query: str, + *, + limit: int | None = None, + ) -> list[CIKResult]: """Search companies by CIK number. Args: @@ -169,9 +177,19 @@ async def search_by_cik(self, query: str) -> list[CIKResult]: Returns: List of matching CIK records. + + Additional keyword arguments (None preserves the existing request): + limit: Maximum number of results requested from FMP. """ + optional_params = { + "limit": limit, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - await self.client.request_async(CIK_SEARCH, query=query), CIKResult + await self.client.request_async(CIK_SEARCH, query=query, **optional_params), + CIKResult, ) async def get_cik_list( @@ -217,12 +235,26 @@ async def get_company_screener( limit: int | None = None, page: int | None = None, include_all_share_classes: bool | None = None, + *, + avg_volume_more_than: int | None = None, + avg_volume_less_than: int | None = None, ) -> list[CompanySearchResult]: """Screen companies based on various criteria. ``page`` is omitted from the request when unset so existing callers keep the same wire shape. Pass ``0`` or a later page to paginate. + + Additional keyword arguments (None preserves the existing request): + avg_volume_more_than: Minimum average trading volume filter. + avg_volume_less_than: Maximum average trading volume filter. """ + optional_params = { + "avg_volume_more_than": avg_volume_more_than, + "avg_volume_less_than": avg_volume_less_than, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } params = { "market_cap_more_than": market_cap_more_than, "market_cap_less_than": market_cap_less_than, @@ -247,11 +279,18 @@ async def get_company_screener( } params = {key: value for key, value in params.items() if value is not None} return self._unwrap_list( - await self.client.request_async(COMPANY_SCREENER, **params), + await self.client.request_async( + COMPANY_SCREENER, **params, **optional_params + ), CompanySearchResult, ) - async def get_market_hours(self, exchange: str = "NYSE") -> MarketHours: + async def get_market_hours( + self, + exchange: str = "NYSE", + *, + timestamp: int | None = None, + ) -> MarketHours: """Get market trading hours information for a specific exchange Args: @@ -262,23 +301,69 @@ async def get_market_hours(self, exchange: str = "NYSE") -> MarketHours: Raises: ValueError: If the API returns an empty list + + Additional keyword arguments (None preserves the existing request): + timestamp: Unix timestamp in seconds at which to evaluate market hours. """ - result = await self.client.request_async(MARKET_HOURS, exchange=exchange) + optional_params = { + "timestamp": timestamp, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } + result = await self.client.request_async( + MARKET_HOURS, exchange=exchange, **optional_params + ) return self._unwrap_single(result, MarketHours) - async def get_all_exchange_market_hours(self) -> list[MarketHours]: - """Get market trading hours information for all exchanges""" + async def get_all_exchange_market_hours( + self, + *, + timestamp: int | None = None, + ) -> list[MarketHours]: + """Get market trading hours information for all exchanges + + Additional keyword arguments (None preserves the existing request): + timestamp: Unix timestamp in seconds at which to evaluate market hours. + """ + optional_params = { + "timestamp": timestamp, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - await self.client.request_async(ALL_EXCHANGE_MARKET_HOURS), + await self.client.request_async( + ALL_EXCHANGE_MARKET_HOURS, **optional_params + ), MarketHours, ) async def get_holidays_by_exchange( - self, exchange: str = "NYSE" + self, + exchange: str = "NYSE", + *, + from_date: dt_date | None = None, + to_date: dt_date | None = None, ) -> list[MarketHoliday]: - """Get market holidays for a specific exchange""" + """Get market holidays for a specific exchange + + Additional keyword arguments (None preserves the existing request): + from_date: Start date passed to FMP; boundary semantics depend on the + endpoint. + to_date: End date passed to FMP; boundary semantics depend on the endpoint. + """ + optional_params = { + "from_date": from_date, + "to_date": to_date, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - await self.client.request_async(HOLIDAYS_BY_EXCHANGE, exchange=exchange), + await self.client.request_async( + HOLIDAYS_BY_EXCHANGE, exchange=exchange, **optional_params + ), MarketHoliday, ) @@ -474,16 +559,49 @@ async def get_pre_post_market(self) -> list[PrePostMarketQuote]: """ return [] - async def get_all_shares_float(self) -> list[ShareFloat]: - """Get share float data for all companies""" + async def get_all_shares_float( + self, + *, + page: int | None = None, + limit: int | None = None, + ) -> list[ShareFloat]: + """Get share float data for all companies + + Additional keyword arguments (None preserves the existing request): + page: Zero-based page number; pagination is controlled by the caller. + limit: Maximum number of results requested from FMP. + """ + optional_params = { + "page": page, + "limit": limit, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - await self.client.request_async(ALL_SHARES_FLOAT), ShareFloat + await self.client.request_async(ALL_SHARES_FLOAT, **optional_params), + ShareFloat, ) - async def get_available_exchanges(self) -> list[ExchangeSymbol]: - """Get a complete list of supported stock exchanges""" + async def get_available_exchanges( + self, + *, + extended: bool | None = None, + ) -> list[ExchangeSymbol]: + """Get a complete list of supported stock exchanges + + Additional keyword arguments (None preserves the existing request): + extended: Include extended exchange listings. + """ + optional_params = { + "extended": extended, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - await self.client.request_async(AVAILABLE_EXCHANGES), ExchangeSymbol + await self.client.request_async(AVAILABLE_EXCHANGES, **optional_params), + ExchangeSymbol, ) async def get_available_sectors(self) -> list[str]: diff --git a/fmp_data/market/client.py b/fmp_data/market/client.py index 52baa97c..9a099220 100644 --- a/fmp_data/market/client.py +++ b/fmp_data/market/client.py @@ -91,7 +91,10 @@ def search_symbol( ) def search_exchange_variants(self, query: str) -> list[CompanySearchResult]: - """Search for exchange trading variants of a company""" + """Search exchange variants by ticker (for example ``query="MSFT"``). + + The public ``query`` argument is sent as FMP's ``symbol`` parameter. + """ return self._unwrap_list( self.client.request(SEARCH_EXCHANGE_VARIANTS, query=query), CompanySearchResult, @@ -145,7 +148,12 @@ def get_available_indexes(self) -> list[AvailableIndex]: """Get list of all available indexes""" return self._unwrap_list(self.client.request(AVAILABLE_INDEXES), AvailableIndex) - def search_by_cik(self, query: str) -> list[CIKResult]: + def search_by_cik( + self, + query: str, + *, + limit: int | None = None, + ) -> list[CIKResult]: """Search companies by CIK number. Args: @@ -158,9 +166,18 @@ def search_by_cik(self, query: str) -> list[CIKResult]: Returns: List of matching CIK records. + + Additional keyword arguments (None preserves the existing request): + limit: Maximum number of results requested from FMP. """ + optional_params = { + "limit": limit, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - self.client.request(CIK_SEARCH, query=query), CIKResult + self.client.request(CIK_SEARCH, query=query, **optional_params), CIKResult ) def get_cik_list(self, page: int = 0, limit: int = 1000) -> list[CIKListEntry]: @@ -203,12 +220,26 @@ def get_company_screener( limit: int | None = None, page: int | None = None, include_all_share_classes: bool | None = None, + *, + avg_volume_more_than: int | None = None, + avg_volume_less_than: int | None = None, ) -> list[CompanySearchResult]: """Screen companies based on various criteria. ``page`` is omitted from the request when unset so existing callers keep the same wire shape. Pass ``0`` or a later page to paginate. + + Additional keyword arguments (None preserves the existing request): + avg_volume_more_than: Minimum average trading volume filter. + avg_volume_less_than: Maximum average trading volume filter. """ + optional_params = { + "avg_volume_more_than": avg_volume_more_than, + "avg_volume_less_than": avg_volume_less_than, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } params = { "market_cap_more_than": market_cap_more_than, "market_cap_less_than": market_cap_less_than, @@ -233,10 +264,16 @@ def get_company_screener( } params = {key: value for key, value in params.items() if value is not None} return self._unwrap_list( - self.client.request(COMPANY_SCREENER, **params), CompanySearchResult + self.client.request(COMPANY_SCREENER, **params, **optional_params), + CompanySearchResult, ) - def get_market_hours(self, exchange: str = "NYSE") -> MarketHours: + def get_market_hours( + self, + exchange: str = "NYSE", + *, + timestamp: int | None = None, + ) -> MarketHours: """Get market trading hours information for a specific exchange Args: @@ -247,21 +284,66 @@ def get_market_hours(self, exchange: str = "NYSE") -> MarketHours: Raises: ValueError: If the API returns an empty list + + Additional keyword arguments (None preserves the existing request): + timestamp: Unix timestamp in seconds at which to evaluate market hours. """ - result = self.client.request(MARKET_HOURS, exchange=exchange) + optional_params = { + "timestamp": timestamp, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } + result = self.client.request(MARKET_HOURS, exchange=exchange, **optional_params) return self._unwrap_single(result, MarketHours) - def get_all_exchange_market_hours(self) -> list[MarketHours]: - """Get market trading hours information for all exchanges""" + def get_all_exchange_market_hours( + self, + *, + timestamp: int | None = None, + ) -> list[MarketHours]: + """Get market trading hours information for all exchanges + + Additional keyword arguments (None preserves the existing request): + timestamp: Unix timestamp in seconds at which to evaluate market hours. + """ + optional_params = { + "timestamp": timestamp, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - self.client.request(ALL_EXCHANGE_MARKET_HOURS), + self.client.request(ALL_EXCHANGE_MARKET_HOURS, **optional_params), MarketHours, ) - def get_holidays_by_exchange(self, exchange: str = "NYSE") -> list[MarketHoliday]: - """Get market holidays for a specific exchange""" + def get_holidays_by_exchange( + self, + exchange: str = "NYSE", + *, + from_date: dt_date | None = None, + to_date: dt_date | None = None, + ) -> list[MarketHoliday]: + """Get market holidays for a specific exchange + + Additional keyword arguments (None preserves the existing request): + from_date: Start date passed to FMP; boundary semantics depend on the + endpoint. + to_date: End date passed to FMP; boundary semantics depend on the endpoint. + """ + optional_params = { + "from_date": from_date, + "to_date": to_date, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - self.client.request(HOLIDAYS_BY_EXCHANGE, exchange=exchange), MarketHoliday + self.client.request( + HOLIDAYS_BY_EXCHANGE, exchange=exchange, **optional_params + ), + MarketHoliday, ) def get_gainers(self) -> list[MarketMover]: @@ -449,14 +531,47 @@ def get_pre_post_market(self) -> list[PrePostMarketQuote]: """ return [] - def get_all_shares_float(self) -> list[ShareFloat]: - """Get share float data for all companies""" - return self._unwrap_list(self.client.request(ALL_SHARES_FLOAT), ShareFloat) + def get_all_shares_float( + self, + *, + page: int | None = None, + limit: int | None = None, + ) -> list[ShareFloat]: + """Get share float data for all companies + + Additional keyword arguments (None preserves the existing request): + page: Zero-based page number; pagination is controlled by the caller. + limit: Maximum number of results requested from FMP. + """ + optional_params = { + "page": page, + "limit": limit, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } + return self._unwrap_list( + self.client.request(ALL_SHARES_FLOAT, **optional_params), ShareFloat + ) + + def get_available_exchanges( + self, + *, + extended: bool | None = None, + ) -> list[ExchangeSymbol]: + """Get a complete list of supported stock exchanges - def get_available_exchanges(self) -> list[ExchangeSymbol]: - """Get a complete list of supported stock exchanges""" + Additional keyword arguments (None preserves the existing request): + extended: Include extended exchange listings. + """ + optional_params = { + "extended": extended, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } return self._unwrap_list( - self.client.request(AVAILABLE_EXCHANGES), ExchangeSymbol + self.client.request(AVAILABLE_EXCHANGES, **optional_params), ExchangeSymbol ) def get_available_sectors(self) -> list[str]: diff --git a/fmp_data/market/endpoints.py b/fmp_data/market/endpoints.py index b5fba474..6fc8855a 100644 --- a/fmp_data/market/endpoints.py +++ b/fmp_data/market/endpoints.py @@ -262,7 +262,14 @@ valid_values=None, ) ], - optional_params=[], + optional_params=[ + EndpointParam( + name="timestamp", + location=ParamLocation.QUERY, + param_type=ParamType.INTEGER, + description="Unix timestamp in seconds at which to evaluate market hours.", + ), + ], response_model=MarketHours, ) @@ -272,7 +279,14 @@ version=APIVersion.STABLE, description="Get market trading hours for all exchanges", mandatory_params=[], - optional_params=[], + optional_params=[ + EndpointParam( + name="timestamp", + location=ParamLocation.QUERY, + param_type=ParamType.INTEGER, + description="Unix timestamp in seconds at which to evaluate market hours.", + ), + ], response_model=MarketHours, ) @@ -290,7 +304,26 @@ valid_values=None, ) ], - optional_params=[], + optional_params=[ + EndpointParam( + name="from_date", + alias="from", + location=ParamLocation.QUERY, + param_type=ParamType.DATE, + description=( + "Start date passed to FMP; boundary semantics depend on the endpoint." + ), + ), + EndpointParam( + name="to_date", + alias="to", + location=ParamLocation.QUERY, + param_type=ParamType.DATE, + description=( + "End date passed to FMP; boundary semantics depend on the endpoint." + ), + ), + ], response_model=MarketHoliday, ) @@ -613,7 +646,22 @@ "companies based on their float characteristics." ), mandatory_params=[], - optional_params=[], + optional_params=[ + EndpointParam( + name="page", + location=ParamLocation.QUERY, + param_type=ParamType.INTEGER, + description=( + "Zero-based page number; pagination is controlled by the caller." + ), + ), + EndpointParam( + name="limit", + location=ParamLocation.QUERY, + param_type=ParamType.INTEGER, + description="Maximum number of results requested from FMP.", + ), + ], response_model=ShareFloat, example_queries=[ "Get share float data for all companies", @@ -870,6 +918,20 @@ description="Include all share classes in results", alias="includeAllShareClasses", ), + EndpointParam( + name="avg_volume_more_than", + alias="avgVolumeMoreThan", + location=ParamLocation.QUERY, + param_type=ParamType.INTEGER, + description="Minimum average trading volume filter.", + ), + EndpointParam( + name="avg_volume_less_than", + alias="avgVolumeLowerThan", + location=ParamLocation.QUERY, + param_type=ParamType.INTEGER, + description="Maximum average trading volume filter.", + ), ], response_model=CompanySearchResult, ) @@ -881,6 +943,7 @@ description="Search for exchange trading variants of a company", mandatory_params=[ EndpointParam( + alias="symbol", name="query", location=ParamLocation.QUERY, param_type=ParamType.STRING, @@ -897,7 +960,14 @@ version=APIVersion.STABLE, description="Get a complete list of supported stock exchanges", mandatory_params=[], - optional_params=[], + optional_params=[ + EndpointParam( + name="extended", + location=ParamLocation.QUERY, + param_type=ParamType.BOOLEAN, + description="Include extended exchange listings.", + ), + ], response_model=ExchangeSymbol, ) diff --git a/fmp_data/market/mapping.py b/fmp_data/market/mapping.py index bd27cb6a..ecf08721 100644 --- a/fmp_data/market/mapping.py +++ b/fmp_data/market/mapping.py @@ -1,5 +1,4 @@ # fmp_data/market/mapping.py - from typing import Any from fmp_data.lc.hints import ( @@ -8,7 +7,9 @@ FROM_TO_DATE_HINTS, INDUSTRY_HINT, LIMIT_HINT, + PAGE_HINT, SECTOR_HINT, + TIMESTAMP_HINT, ) from fmp_data.lc.models import EndpointSemantics, ResponseFieldInfo, SemanticCategory from fmp_data.market.endpoints import ( @@ -378,7 +379,10 @@ ], category=SemanticCategory.MARKET_DATA, sub_category="Float", - parameter_hints={}, # No parameters needed + parameter_hints={ + "page": PAGE_HINT, + "limit": LIMIT_HINT, + }, response_hints={ "symbol": ResponseFieldInfo( description="Company stock symbol", @@ -430,7 +434,7 @@ ], category=SemanticCategory.MARKET_DATA, sub_category="Lists", - parameter_hints={}, # No parameters needed + parameter_hints={}, response_hints={ "symbol": ResponseFieldInfo( description="ETF trading symbol", @@ -474,7 +478,7 @@ ], category=SemanticCategory.MARKET_DATA, sub_category="Lists", - parameter_hints={}, # No parameters needed + parameter_hints={}, response_hints={ "symbol": ResponseFieldInfo( description="Index symbol", @@ -516,7 +520,10 @@ ], category=SemanticCategory.MARKET_DATA, sub_category="Market Status", - parameter_hints={"exchange": EXCHANGE_HINT}, + parameter_hints={ + "exchange": EXCHANGE_HINT, + "timestamp": TIMESTAMP_HINT, + }, response_hints={ "exchange": ResponseFieldInfo( description="Exchange code", @@ -574,7 +581,9 @@ ], category=SemanticCategory.MARKET_DATA, sub_category="Market Status", - parameter_hints={}, # No parameters required + parameter_hints={ + "timestamp": TIMESTAMP_HINT, + }, response_hints={ "exchange": ResponseFieldInfo( description="Exchange code", @@ -623,7 +632,11 @@ ], category=SemanticCategory.MARKET_DATA, sub_category="Market Calendar", - parameter_hints={"exchange": EXCHANGE_HINT}, + parameter_hints={ + "exchange": EXCHANGE_HINT, + "from_date": DATE_HINTS["start_date"], + "to_date": DATE_HINTS["end_date"], + }, response_hints={ "date": ResponseFieldInfo( description="Holiday date", @@ -670,7 +683,7 @@ ], category=SemanticCategory.MARKET_DATA, sub_category="Market Movers", - parameter_hints={}, # No parameters needed + parameter_hints={}, response_hints={ "symbol": ResponseFieldInfo( description="Stock symbol", @@ -715,7 +728,7 @@ ], category=SemanticCategory.MARKET_DATA, sub_category="Market Movers", - parameter_hints={}, # No parameters needed + parameter_hints={}, response_hints={ "symbol": ResponseFieldInfo( description="Stock symbol", @@ -760,7 +773,7 @@ ], category=SemanticCategory.MARKET_DATA, sub_category="Market Activity", - parameter_hints={}, # No parameters needed + parameter_hints={}, response_hints={ "symbol": ResponseFieldInfo( description="Stock symbol", @@ -1123,7 +1136,7 @@ ], category=SemanticCategory.MARKET_DATA, sub_category="Extended Hours Trading", - parameter_hints={}, # No parameters needed + parameter_hints={}, response_hints={ "symbol": ResponseFieldInfo( description="Stock symbol", @@ -1184,7 +1197,7 @@ ], category=SemanticCategory.MARKET_DATA, sub_category="Lists", - parameter_hints={}, # No parameters needed + parameter_hints={}, response_hints={ "symbol": ResponseFieldInfo( description="Stock trading symbol", diff --git a/fmp_data/mcp/_compat.py b/fmp_data/mcp/_compat.py index c099006b..b8f0a0d3 100644 --- a/fmp_data/mcp/_compat.py +++ b/fmp_data/mcp/_compat.py @@ -10,6 +10,7 @@ from __future__ import annotations +from importlib import import_module from typing import Any, cast # The concrete class differs per SDK major, so the public surface is typed as @@ -34,10 +35,12 @@ def import_mcp_server_class() -> type[Any]: else: return cast(type[Any], MCPServer) - # MCP SDK 1.x - from mcp.server.fastmcp import FastMCP - - return cast(type[Any], FastMCP) + # MCP SDK 1.x. Resolve dynamically: the 2.2 stubs no longer export FastMCP, + # so a static import cannot type-check with the current optional extra. + server_class = getattr(import_module("mcp.server.fastmcp"), "FastMCP", None) + if server_class is None: + raise ImportError("No supported MCP server class found") + return cast(type[Any], server_class) def mcp_server_available() -> bool: diff --git a/fmp_data/sec/async_client.py b/fmp_data/sec/async_client.py index fe6f6ae3..d80af49d 100644 --- a/fmp_data/sec/async_client.py +++ b/fmp_data/sec/async_client.py @@ -280,13 +280,31 @@ async def get_profile(self, symbol: str) -> SECProfile | None: return None return self._unwrap_single(result, SECProfile, allow_none=True) - async def get_sic_codes(self) -> list[SICCode]: + async def get_sic_codes( + self, + *, + industry_title: str | None = None, + sic_code: str | None = None, + ) -> list[SICCode]: """Get list of all Standard Industrial Classification (SIC) codes Returns: List of SIC codes + + Additional keyword arguments (None preserves the existing request): + industry_title: Filter SIC classifications by industry title. + sic_code: Filter by SIC code; kept as a string to preserve leading zeros. """ - return self._unwrap_list(await self.client.request_async(SIC_LIST), SICCode) + optional_params = { + "industry_title": industry_title, + "sic_code": sic_code, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } + return self._unwrap_list( + await self.client.request_async(SIC_LIST, **optional_params), SICCode + ) async def search_industry_classification( self, diff --git a/fmp_data/sec/client.py b/fmp_data/sec/client.py index 974761f9..1da042d5 100644 --- a/fmp_data/sec/client.py +++ b/fmp_data/sec/client.py @@ -274,13 +274,31 @@ def get_profile(self, symbol: str) -> SECProfile | None: return None return self._unwrap_single(result, SECProfile, allow_none=True) - def get_sic_codes(self) -> list[SICCode]: + def get_sic_codes( + self, + *, + industry_title: str | None = None, + sic_code: str | None = None, + ) -> list[SICCode]: """Get list of all Standard Industrial Classification (SIC) codes Returns: List of SIC codes + + Additional keyword arguments (None preserves the existing request): + industry_title: Filter SIC classifications by industry title. + sic_code: Filter by SIC code; kept as a string to preserve leading zeros. """ - return self._unwrap_list(self.client.request(SIC_LIST), SICCode) + optional_params = { + "industry_title": industry_title, + "sic_code": sic_code, + } + optional_params = { + key: value for key, value in optional_params.items() if value is not None + } + return self._unwrap_list( + self.client.request(SIC_LIST, **optional_params), SICCode + ) def search_industry_classification( self, diff --git a/fmp_data/sec/endpoints.py b/fmp_data/sec/endpoints.py index cc06481a..36f4b0f0 100644 --- a/fmp_data/sec/endpoints.py +++ b/fmp_data/sec/endpoints.py @@ -380,7 +380,24 @@ method=HTTPMethod.GET, description="Get list of all Standard Industrial Classification (SIC) codes", mandatory_params=[], - optional_params=[], + optional_params=[ + EndpointParam( + name="industry_title", + alias="industryTitle", + location=ParamLocation.QUERY, + param_type=ParamType.STRING, + description="Filter SIC classifications by industry title.", + ), + EndpointParam( + name="sic_code", + alias="sicCode", + location=ParamLocation.QUERY, + param_type=ParamType.STRING, + description=( + "Filter by SIC code; kept as a string to preserve leading zeros." + ), + ), + ], response_model=SICCode, example_queries=[ "Get all SIC codes", diff --git a/fmp_data/sec/mapping.py b/fmp_data/sec/mapping.py index 8031e812..89ee10e8 100644 --- a/fmp_data/sec/mapping.py +++ b/fmp_data/sec/mapping.py @@ -368,7 +368,24 @@ "industry taxonomy", ], category=SemanticCategory.COMPANY_INFO, - parameter_hints={}, + parameter_hints={ + "industry_title": ParameterHint( + natural_names=["industry title"], + extraction_patterns=[], + examples=["SERVICES"], + context_clues=["Filter SIC classifications by industry title."], + required=False, + ), + "sic_code": ParameterHint( + natural_names=["sic code"], + extraction_patterns=[], + examples=["7371"], + context_clues=[ + ("Filter by SIC code; kept as a string to preserve leading zeros.") + ], + required=False, + ), + }, response_hints={}, use_cases=["Financial analysis", "Investment research"], ), diff --git a/mkdocs.yml b/mkdocs.yml index 9764a585..181fc234 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,7 @@ nav: - API: - Reference: api/reference.md - Endpoints: api/endpoints.md + - Optional Parameters: api/optional-parameters.md markdown_extensions: - pymdownx.highlight: diff --git a/noxfile.py b/noxfile.py index 1ca25709..84cacd0d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -82,7 +82,7 @@ def _sync_with_uv(session: Session, extras: Iterable[str] = ()) -> None: return # Install uv in the session if not available - session.install("uv") + session.install("uv>=0.12.13") # Install the base package first session.run("uv", "pip", "install", "-e", ".") @@ -118,6 +118,20 @@ def _mcp_unit_tests() -> list[str]: # --------------------------------------------------------------------------- # +@nox.session(python=False) +def ci_check(session: Session) -> None: + """Run the existing gates used by ``make ci`` and ``make check-all``.""" + for gate in ( + "lint", + "typecheck", + "security", + "coverage_local", + "coverage_extras", + "smoke", + ): + session.notify(gate) + + @nox.session(python=PY_VERSIONS, tags=["tests"]) @nox.parametrize("feature_group", FEATURE_GROUPS, ids=FEATURE_IDS) def tests(session: Session, feature_group: str | None) -> None: @@ -354,7 +368,7 @@ def security(session: Session) -> None: session time (no committed hashed lock), so a floor bump is enough to pick up newer deps (#252 FMP-SEC-008). """ - session.install("uv", "pip-audit>=2.10.1", "bandit[toml]>=1.8.0") + session.install("uv>=0.12.13", "pip-audit>=2.10.1", "bandit[toml]>=1.9.4") session.run("bandit", "-c", "pyproject.toml", "-r", PACKAGE_NAME) export = Path(session.create_tmp()) / "requirements-audit.txt" session.run( diff --git a/pyproject.toml b/pyproject.toml index 6988214c..355cf3a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] requires = [ - "hatchling>=1.31.0", + "hatchling>=1.32.0", "hatch-vcs>=0.5.0", ] build-backend = "hatchling.build" @@ -47,7 +47,7 @@ classifiers = [ ] dependencies = [ "httpx>=0.28.1", - "pydantic>=2.13.4", + "pydantic>=2.13.5", "tenacity>=9.1.4", ] @@ -64,94 +64,93 @@ fmp-mcp = "fmp_data.mcp.cli:main" [project.optional-dependencies] mcp = [ - # MCP Python SDK 2.0 is stable; fmp_data.mcp._compat supports 1.x and 2.x. - # Floor at 2.0 so fresh installs land on the maintained line (1.x is - # security-fixes only). - "mcp[cli]>=2.0.0", + # MCP Python SDK 2.2 is stable; fmp_data.mcp._compat retains the 1.x + # fallback while fresh installs use the maintained 2.x line. + "mcp[cli]>=2.2.0", "pyyaml>=6.0.3", # stdlib tomllib is 3.11+; keep TOML manifests working on 3.10. - "tomli>=2.0.1; python_version < '3.11'", + "tomli>=2.4.1; python_version < '3.11'", ] cache-redis = [ "redis>=8.1.0", ] langchain = [ "faiss-cpu>=1.15.0", - "langchain-core>=1.5.3", + "langchain-core>=1.6.3", "langchain-community>=0.4.2", - "langchain-openai>=1.4.2", - "langgraph>=1.2.10", - "openai>=2.53.0", - "tiktoken>=0.13.0", + "langchain-openai>=1.6.2", + "langgraph>=1.2.11", + "openai>=3.13.0", + "tiktoken>=0.14.0", ] dev = [ - "ruff>=0.16.2", - "mypy>=2.3.0", + "ruff>=0.16.7", + "mypy>=2.3.1", "bandit[toml]>=1.9.4", "pip-audit>=2.10.1", - "python-dotenv>=1.2.2", + "python-dotenv>=1.2.3", "pytest>=9.1.1", "pytest-asyncio>=1.4.0", "pytest-cov>=7.1.0", "pytest-mock>=3.15.1", "pytest-xdist>=3.8.0", "freezegun>=1.5.5", - "responses>=0.26.2", + "responses>=0.26.3", "vcrpy>=8.3.0", - "coverage>=7.15.4", - "pre-commit>=4.6.1", + "coverage>=7.16.0", + "pre-commit>=4.6.2", "rich>=15.0.0", "twine>=7.0.0", - "nox>=2026.7.11", + "nox>=2026.8.17", ] docs = [ "mkdocs>=1.6.1", "mkdocs-material>=9.7.7", - "mkdocstrings-python>=2.0.5", + "mkdocstrings-python>=2.0.8", ] [dependency-groups] dev = [ - "ruff>=0.16.2", - "mypy>=2.3.0", + "ruff>=0.16.7", + "mypy>=2.3.1", "bandit[toml]>=1.9.4", "pip-audit>=2.10.1", - "python-dotenv>=1.2.2", + "python-dotenv>=1.2.3", "pytest>=9.1.1", "pytest-asyncio>=1.4.0", "pytest-cov>=7.1.0", "pytest-mock>=3.15.1", "pytest-xdist>=3.8.0", "freezegun>=1.5.5", - "responses>=0.26.2", + "responses>=0.26.3", "vcrpy>=8.3.0", - "coverage>=7.15.4", - "pre-commit>=4.6.1", + "coverage>=7.16.0", + "pre-commit>=4.6.2", "rich>=15.0.0", "twine>=7.0.0", - "nox>=2026.7.11", + "nox>=2026.8.17", ] docs = [ "mkdocs>=1.6.1", "mkdocs-material>=9.7.7", - "mkdocstrings-python>=2.0.5", + "mkdocstrings-python>=2.0.8", ] langchain = [ "faiss-cpu>=1.15.0", - "langchain-core>=1.5.3", + "langchain-core>=1.6.3", "langchain-community>=0.4.2", - "langchain-openai>=1.4.2", - "langgraph>=1.2.10", - "openai>=2.53.0", - "tiktoken>=0.13.0", + "langchain-openai>=1.6.2", + "langgraph>=1.2.11", + "openai>=3.13.0", + "tiktoken>=0.14.0", ] mcp = [ - "mcp[cli]>=2.0.0", + "mcp[cli]>=2.2.0", "pyyaml>=6.0.3", ] [tool.uv] -required-version = ">=0.12.3" +required-version = ">=0.12.13" [tool.hatch.version] source = "vcs" diff --git a/tests/fixtures/provider_optional_parameters.json b/tests/fixtures/provider_optional_parameters.json new file mode 100644 index 00000000..f5ef0282 --- /dev/null +++ b/tests/fixtures/provider_optional_parameters.json @@ -0,0 +1,1304 @@ +{ + "provider_checked": "2026-09-13", + "baseline_commit": "02d2a90", + "cases": [ + { + "group": "market", + "method": "get_holidays_by_exchange", + "endpoint": "HOLIDAYS_BY_EXCHANGE", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/holidays-by-exchange", + "args": [], + "baseline_request": { + "path": "/stable/holidays-by-exchange", + "query": { + "exchange": "NYSE" + } + }, + "baseline_parameters": [ + { + "name": "exchange", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "'NYSE'" + } + ], + "baseline_annotations": { + "exchange": "str" + }, + "return_annotation": "list[MarketHoliday]", + "new_parameters": [ + { + "name": "from_date", + "wire": "from", + "type": "date", + "sample": "2026-09-10" + }, + { + "name": "to_date", + "wire": "to", + "type": "date", + "sample": "2026-09-11" + } + ] + }, + { + "group": "market", + "method": "get_company_screener", + "endpoint": "COMPANY_SCREENER", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/search-company-screener", + "args": [], + "baseline_request": { + "path": "/stable/company-screener", + "query": {} + }, + "baseline_parameters": [ + { + "name": "market_cap_more_than", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "market_cap_less_than", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "price_more_than", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "price_less_than", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "beta_more_than", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "beta_less_than", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "volume_more_than", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "volume_less_than", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "dividend_more_than", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "dividend_less_than", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "is_etf", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "is_fund", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "is_actively_trading", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "sector", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "industry", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "country", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "exchange", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "limit", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "page", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "include_all_share_classes", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + } + ], + "baseline_annotations": { + "market_cap_more_than": "float | None", + "market_cap_less_than": "float | None", + "price_more_than": "float | None", + "price_less_than": "float | None", + "beta_more_than": "float | None", + "beta_less_than": "float | None", + "volume_more_than": "int | None", + "volume_less_than": "int | None", + "dividend_more_than": "float | None", + "dividend_less_than": "float | None", + "is_etf": "bool | None", + "is_fund": "bool | None", + "is_actively_trading": "bool | None", + "sector": "str | None", + "industry": "str | None", + "country": "str | None", + "exchange": "str | None", + "limit": "int | None", + "page": "int | None", + "include_all_share_classes": "bool | None" + }, + "return_annotation": "list[CompanySearchResult]", + "new_parameters": [ + { + "name": "avg_volume_more_than", + "wire": "avgVolumeMoreThan", + "type": "int", + "sample": 0 + }, + { + "name": "avg_volume_less_than", + "wire": "avgVolumeLowerThan", + "type": "int", + "sample": 0 + } + ] + }, + { + "group": "market", + "method": "get_market_hours", + "endpoint": "MARKET_HOURS", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/exchange-market-hours", + "args": [], + "baseline_request": { + "path": "/stable/exchange-market-hours", + "query": { + "exchange": "NYSE" + } + }, + "baseline_parameters": [ + { + "name": "exchange", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "'NYSE'" + } + ], + "baseline_annotations": { + "exchange": "str" + }, + "return_annotation": "MarketHours", + "new_parameters": [ + { + "name": "timestamp", + "wire": "timestamp", + "type": "int", + "sample": 0 + } + ] + }, + { + "group": "market", + "method": "get_all_exchange_market_hours", + "endpoint": "ALL_EXCHANGE_MARKET_HOURS", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/all-exchange-market-hours", + "args": [], + "baseline_request": { + "path": "/stable/all-exchange-market-hours", + "query": {} + }, + "baseline_parameters": [], + "baseline_annotations": {}, + "return_annotation": "list[MarketHours]", + "new_parameters": [ + { + "name": "timestamp", + "wire": "timestamp", + "type": "int", + "sample": 0 + } + ] + }, + { + "group": "market", + "method": "get_all_shares_float", + "endpoint": "ALL_SHARES_FLOAT", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/all-shares-float", + "args": [], + "baseline_request": { + "path": "/stable/shares-float-all", + "query": {} + }, + "baseline_parameters": [], + "baseline_annotations": {}, + "return_annotation": "list[ShareFloat]", + "new_parameters": [ + { + "name": "page", + "wire": "page", + "type": "int", + "sample": 0 + }, + { + "name": "limit", + "wire": "limit", + "type": "int", + "sample": 0 + } + ] + }, + { + "group": "market", + "method": "get_available_exchanges", + "endpoint": "AVAILABLE_EXCHANGES", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/available-exchanges", + "args": [], + "baseline_request": { + "path": "/stable/available-exchanges", + "query": {} + }, + "baseline_parameters": [], + "baseline_annotations": {}, + "return_annotation": "list[ExchangeSymbol]", + "new_parameters": [ + { + "name": "extended", + "wire": "extended", + "type": "bool", + "sample": false + } + ] + }, + { + "group": "market", + "method": "search_by_cik", + "endpoint": "CIK_SEARCH", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/search-cik", + "args": [ + "320193" + ], + "baseline_request": { + "path": "/stable/search-cik", + "query": { + "cik": "0000320193", + "limit": "50" + } + }, + "baseline_parameters": [ + { + "name": "query", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "" + } + ], + "baseline_annotations": { + "query": "str" + }, + "return_annotation": "list[CIKResult]", + "new_parameters": [ + { + "name": "limit", + "wire": "limit", + "type": "int", + "sample": 0 + } + ] + }, + { + "group": "company", + "method": "get_employee_count", + "endpoint": "EMPLOYEE_COUNT", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/employee-count", + "args": [ + "MSFT" + ], + "baseline_request": { + "path": "/stable/employee-count", + "query": { + "symbol": "MSFT" + } + }, + "baseline_parameters": [ + { + "name": "symbol", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "" + } + ], + "baseline_annotations": { + "symbol": "str" + }, + "return_annotation": "list[EmployeeCount]", + "new_parameters": [ + { + "name": "limit", + "wire": "limit", + "type": "int", + "sample": 0 + } + ] + }, + { + "group": "company", + "method": "get_historical_market_cap", + "endpoint": "HISTORICAL_MARKET_CAP", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/historical-market-cap", + "args": [ + "MSFT" + ], + "baseline_request": { + "path": "/stable/historical-market-capitalization", + "query": { + "symbol": "MSFT" + } + }, + "baseline_parameters": [ + { + "name": "symbol", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "" + } + ], + "baseline_annotations": { + "symbol": "str" + }, + "return_annotation": "list[MarketCapitalization]", + "new_parameters": [ + { + "name": "from_date", + "wire": "from", + "type": "date", + "sample": "2026-09-10" + }, + { + "name": "to_date", + "wire": "to", + "type": "date", + "sample": "2026-09-11" + }, + { + "name": "limit", + "wire": "limit", + "type": "int", + "sample": 0 + } + ] + }, + { + "group": "company", + "method": "get_symbol_changes", + "endpoint": "SYMBOL_CHANGES", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/symbol-changes-list", + "args": [], + "baseline_request": { + "path": "/stable/symbol-change", + "query": {} + }, + "baseline_parameters": [], + "baseline_annotations": {}, + "return_annotation": "list[SymbolChange]", + "new_parameters": [ + { + "name": "invalid", + "wire": "invalid", + "type": "str", + "sample": "false" + }, + { + "name": "limit", + "wire": "limit", + "type": "int", + "sample": 0 + } + ] + }, + { + "group": "company", + "method": "get_intraday_prices", + "endpoint": "INTRADAY_PRICE", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/intraday-5-min", + "args": [ + "MSFT" + ], + "baseline_request": { + "path": "/stable/historical-chart/1min", + "query": { + "symbol": "MSFT" + } + }, + "baseline_parameters": [ + { + "name": "symbol", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "" + }, + { + "name": "interval", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "'1min'" + }, + { + "name": "from_date", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "to_date", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "nonadjusted", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + } + ], + "baseline_annotations": { + "symbol": "str", + "interval": "Interval", + "from_date": "date | None", + "to_date": "date | None", + "nonadjusted": "bool | None" + }, + "return_annotation": "list[IntradayPrice]", + "new_parameters": [ + { + "name": "extended", + "wire": "extended", + "type": "bool", + "sample": false + } + ] + }, + { + "group": "company", + "method": "get_earnings", + "endpoint": "COMPANY_EARNINGS", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/earnings-company", + "args": [ + "MSFT" + ], + "baseline_request": { + "path": "/stable/earnings", + "query": { + "symbol": "MSFT", + "limit": "20" + } + }, + "baseline_parameters": [ + { + "name": "symbol", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "" + }, + { + "name": "limit", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "20" + } + ], + "baseline_annotations": { + "symbol": "str", + "limit": "int" + }, + "return_annotation": "list[EarningEvent]", + "new_parameters": [ + { + "name": "include_report_times", + "wire": "includeReportTimes", + "type": "bool", + "sample": false + } + ] + }, + { + "group": "economics", + "method": "get_economic_indicators", + "endpoint": "ECONOMIC_INDICATORS", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/economics-indicators", + "args": [ + "GDP" + ], + "baseline_request": { + "path": "/stable/economic-indicators", + "query": { + "name": "GDP" + } + }, + "baseline_parameters": [ + { + "name": "indicator_name", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "" + } + ], + "baseline_annotations": { + "indicator_name": "EconomicIndicatorType | str" + }, + "return_annotation": "list[EconomicIndicator]", + "new_parameters": [ + { + "name": "start_date", + "wire": "from", + "type": "date", + "sample": "2026-09-10" + }, + { + "name": "end_date", + "wire": "to", + "type": "date", + "sample": "2026-09-11" + } + ] + }, + { + "group": "economics", + "method": "get_economic_calendar", + "endpoint": "ECONOMIC_CALENDAR", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/economics-calendar", + "args": [], + "baseline_request": { + "path": "/stable/economic-calendar", + "query": {} + }, + "baseline_parameters": [ + { + "name": "start_date", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "end_date", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + } + ], + "baseline_annotations": { + "start_date": "date | None", + "end_date": "date | None" + }, + "return_annotation": "list[EconomicEvent]", + "new_parameters": [ + { + "name": "country", + "wire": "country", + "type": "str", + "sample": "US" + } + ] + }, + { + "group": "fundamental", + "method": "get_custom_discounted_cash_flow", + "endpoint": "CUSTOM_DISCOUNTED_CASH_FLOW", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/custom-dcf-advanced", + "args": [ + "MSFT" + ], + "baseline_request": { + "path": "/stable/custom-discounted-cash-flow", + "query": { + "symbol": "MSFT" + } + }, + "baseline_parameters": [ + { + "name": "symbol", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "" + } + ], + "baseline_annotations": { + "symbol": "str" + }, + "return_annotation": "list[CustomDCF]", + "new_parameters": [ + { + "name": "revenue_growth_pct", + "wire": "revenueGrowthPct", + "type": "float", + "sample": 0.0 + }, + { + "name": "ebitda_pct", + "wire": "ebitdaPct", + "type": "float", + "sample": 0.0 + }, + { + "name": "depreciation_and_amortization_pct", + "wire": "depreciationAndAmortizationPct", + "type": "float", + "sample": 0.0 + }, + { + "name": "cash_and_short_term_investments_pct", + "wire": "cashAndShortTermInvestmentsPct", + "type": "float", + "sample": 0.0 + }, + { + "name": "receivables_pct", + "wire": "receivablesPct", + "type": "float", + "sample": 0.0 + }, + { + "name": "inventories_pct", + "wire": "inventoriesPct", + "type": "float", + "sample": 0.0 + }, + { + "name": "payable_pct", + "wire": "payablePct", + "type": "float", + "sample": 0.0 + }, + { + "name": "ebit_pct", + "wire": "ebitPct", + "type": "float", + "sample": 0.0 + }, + { + "name": "capital_expenditure_pct", + "wire": "capitalExpenditurePct", + "type": "float", + "sample": 0.0 + }, + { + "name": "operating_cash_flow_pct", + "wire": "operatingCashFlowPct", + "type": "float", + "sample": 0.0 + }, + { + "name": "selling_general_and_administrative_expenses_pct", + "wire": "sellingGeneralAndAdministrativeExpensesPct", + "type": "float", + "sample": 0.0 + }, + { + "name": "tax_rate", + "wire": "taxRate", + "type": "float", + "sample": 0.0 + }, + { + "name": "long_term_growth_rate", + "wire": "longTermGrowthRate", + "type": "float", + "sample": 0.0 + }, + { + "name": "cost_of_debt", + "wire": "costOfDebt", + "type": "float", + "sample": 0.0 + }, + { + "name": "cost_of_equity", + "wire": "costOfEquity", + "type": "float", + "sample": 0.0 + }, + { + "name": "market_risk_premium", + "wire": "marketRiskPremium", + "type": "float", + "sample": 0.0 + }, + { + "name": "beta", + "wire": "beta", + "type": "float", + "sample": 0.0 + }, + { + "name": "risk_free_rate", + "wire": "riskFreeRate", + "type": "float", + "sample": 0.0 + } + ] + }, + { + "group": "fundamental", + "method": "get_custom_levered_dcf", + "endpoint": "CUSTOM_LEVERED_DCF", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/custom-dcf-levered", + "args": [ + "MSFT" + ], + "baseline_request": { + "path": "/stable/custom-levered-discounted-cash-flow", + "query": { + "symbol": "MSFT" + } + }, + "baseline_parameters": [ + { + "name": "symbol", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "" + } + ], + "baseline_annotations": { + "symbol": "str" + }, + "return_annotation": "list[CustomLeveredDCF]", + "new_parameters": [ + { + "name": "revenue_growth_pct", + "wire": "revenueGrowthPct", + "type": "float", + "sample": 0.0 + }, + { + "name": "ebitda_pct", + "wire": "ebitdaPct", + "type": "float", + "sample": 0.0 + }, + { + "name": "depreciation_and_amortization_pct", + "wire": "depreciationAndAmortizationPct", + "type": "float", + "sample": 0.0 + }, + { + "name": "cash_and_short_term_investments_pct", + "wire": "cashAndShortTermInvestmentsPct", + "type": "float", + "sample": 0.0 + }, + { + "name": "receivables_pct", + "wire": "receivablesPct", + "type": "float", + "sample": 0.0 + }, + { + "name": "inventories_pct", + "wire": "inventoriesPct", + "type": "float", + "sample": 0.0 + }, + { + "name": "payable_pct", + "wire": "payablePct", + "type": "float", + "sample": 0.0 + }, + { + "name": "ebit_pct", + "wire": "ebitPct", + "type": "float", + "sample": 0.0 + }, + { + "name": "capital_expenditure_pct", + "wire": "capitalExpenditurePct", + "type": "float", + "sample": 0.0 + }, + { + "name": "operating_cash_flow_pct", + "wire": "operatingCashFlowPct", + "type": "float", + "sample": 0.0 + }, + { + "name": "selling_general_and_administrative_expenses_pct", + "wire": "sellingGeneralAndAdministrativeExpensesPct", + "type": "float", + "sample": 0.0 + }, + { + "name": "tax_rate", + "wire": "taxRate", + "type": "float", + "sample": 0.0 + }, + { + "name": "long_term_growth_rate", + "wire": "longTermGrowthRate", + "type": "float", + "sample": 0.0 + }, + { + "name": "cost_of_debt", + "wire": "costOfDebt", + "type": "float", + "sample": 0.0 + }, + { + "name": "cost_of_equity", + "wire": "costOfEquity", + "type": "float", + "sample": 0.0 + }, + { + "name": "market_risk_premium", + "wire": "marketRiskPremium", + "type": "float", + "sample": 0.0 + }, + { + "name": "beta", + "wire": "beta", + "type": "float", + "sample": 0.0 + }, + { + "name": "risk_free_rate", + "wire": "riskFreeRate", + "type": "float", + "sample": 0.0 + } + ] + }, + { + "group": "institutional", + "method": "get_beneficial_ownership", + "endpoint": "BENEFICIAL_OWNERSHIP", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/acquisition-ownership", + "args": [ + "MSFT" + ], + "baseline_request": { + "path": "/stable/acquisition-of-beneficial-ownership", + "query": { + "symbol": "MSFT" + } + }, + "baseline_parameters": [ + { + "name": "symbol", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "" + } + ], + "baseline_annotations": { + "symbol": "str" + }, + "return_annotation": "list[BeneficialOwnership]", + "new_parameters": [ + { + "name": "limit", + "wire": "limit", + "type": "int", + "sample": 0 + } + ] + }, + { + "group": "intelligence", + "method": "get_earnings_calendar", + "endpoint": "EARNINGS_CALENDAR", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/earnings-calendar", + "args": [], + "baseline_request": { + "path": "/stable/earnings-calendar", + "query": {} + }, + "baseline_parameters": [ + { + "name": "start_date", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "end_date", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "include_report_times", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + } + ], + "baseline_annotations": { + "start_date": "date | None", + "end_date": "date | None", + "include_report_times": "bool | None" + }, + "return_annotation": "list[EarningEvent]", + "new_parameters": [ + { + "name": "page", + "wire": "page", + "type": "int", + "sample": 0 + } + ] + }, + { + "group": "intelligence", + "method": "get_dividends_calendar", + "endpoint": "DIVIDENDS_CALENDAR", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/dividends-calendar", + "args": [], + "baseline_request": { + "path": "/stable/dividends-calendar", + "query": {} + }, + "baseline_parameters": [ + { + "name": "start_date", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "end_date", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + } + ], + "baseline_annotations": { + "start_date": "date | None", + "end_date": "date | None" + }, + "return_annotation": "list[DividendEvent]", + "new_parameters": [ + { + "name": "page", + "wire": "page", + "type": "int", + "sample": 0 + } + ] + }, + { + "group": "intelligence", + "method": "get_stock_splits_calendar", + "endpoint": "STOCK_SPLITS_CALENDAR", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/splits-calendar", + "args": [], + "baseline_request": { + "path": "/stable/splits-calendar", + "query": {} + }, + "baseline_parameters": [ + { + "name": "start_date", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + }, + { + "name": "end_date", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "None" + } + ], + "baseline_annotations": { + "start_date": "date | None", + "end_date": "date | None" + }, + "return_annotation": "list[StockSplitEvent]", + "new_parameters": [ + { + "name": "page", + "wire": "page", + "type": "int", + "sample": 0 + } + ] + }, + { + "group": "intelligence", + "method": "get_esg_benchmark", + "endpoint": "ESG_BENCHMARK", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/esg-benchmark", + "args": [], + "baseline_request": { + "path": "/stable/esg-benchmark", + "query": {} + }, + "baseline_parameters": [], + "baseline_annotations": {}, + "return_annotation": "list[ESGBenchmark]", + "new_parameters": [ + { + "name": "year", + "wire": "year", + "type": "int", + "sample": 0 + } + ] + }, + { + "group": "intelligence", + "method": "get_senate_trading", + "endpoint": "SENATE_TRADING", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/senate-trading", + "args": [ + "MSFT" + ], + "baseline_request": { + "path": "/stable/senate-trades", + "query": { + "symbol": "MSFT" + } + }, + "baseline_parameters": [ + { + "name": "symbol", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "" + } + ], + "baseline_annotations": { + "symbol": "str" + }, + "return_annotation": "list[SenateTrade]", + "new_parameters": [ + { + "name": "page", + "wire": "page", + "type": "int", + "sample": 0 + }, + { + "name": "limit", + "wire": "limit", + "type": "int", + "sample": 0 + } + ] + }, + { + "group": "intelligence", + "method": "get_house_disclosure", + "endpoint": "HOUSE_DISCLOSURE", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/house-trading", + "args": [ + "MSFT" + ], + "baseline_request": { + "path": "/stable/house-trades", + "query": { + "symbol": "MSFT" + } + }, + "baseline_parameters": [ + { + "name": "symbol", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "" + } + ], + "baseline_annotations": { + "symbol": "str" + }, + "return_annotation": "list[HouseDisclosure]", + "new_parameters": [ + { + "name": "page", + "wire": "page", + "type": "int", + "sample": 0 + }, + { + "name": "limit", + "wire": "limit", + "type": "int", + "sample": 0 + } + ] + }, + { + "group": "alternative", + "method": "get_crypto_intraday", + "endpoint": "CRYPTO_INTRADAY", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/cryptocurrency-intraday-1-hour", + "args": [ + "MSFT" + ], + "baseline_request": { + "path": "/stable/historical-chart/5min", + "query": { + "symbol": "MSFT" + } + }, + "baseline_parameters": [ + { + "name": "symbol", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "" + }, + { + "name": "interval", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "'5min'" + } + ], + "baseline_annotations": { + "symbol": "str", + "interval": "Interval" + }, + "return_annotation": "list[CryptoIntradayPrice]", + "new_parameters": [ + { + "name": "start_date", + "wire": "from", + "type": "date", + "sample": "2026-09-10" + }, + { + "name": "end_date", + "wire": "to", + "type": "date", + "sample": "2026-09-11" + } + ] + }, + { + "group": "alternative", + "method": "get_forex_intraday", + "endpoint": "FOREX_INTRADAY", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/forex-intraday-1-hour", + "args": [ + "MSFT" + ], + "baseline_request": { + "path": "/stable/historical-chart/5min", + "query": { + "symbol": "MSFT" + } + }, + "baseline_parameters": [ + { + "name": "symbol", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "" + }, + { + "name": "interval", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "'5min'" + } + ], + "baseline_annotations": { + "symbol": "str", + "interval": "Interval" + }, + "return_annotation": "list[ForexIntradayPrice]", + "new_parameters": [ + { + "name": "start_date", + "wire": "from", + "type": "date", + "sample": "2026-09-10" + }, + { + "name": "end_date", + "wire": "to", + "type": "date", + "sample": "2026-09-11" + } + ] + }, + { + "group": "alternative", + "method": "get_commodity_intraday", + "endpoint": "COMMODITY_INTRADAY", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/commodities-intraday-1-hour", + "args": [ + "MSFT" + ], + "baseline_request": { + "path": "/stable/historical-chart/5min", + "query": { + "symbol": "MSFT" + } + }, + "baseline_parameters": [ + { + "name": "symbol", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "" + }, + { + "name": "interval", + "kind": "POSITIONAL_OR_KEYWORD", + "default": "'5min'" + } + ], + "baseline_annotations": { + "symbol": "str", + "interval": "Interval" + }, + "return_annotation": "list[CommodityIntradayPrice]", + "new_parameters": [ + { + "name": "start_date", + "wire": "from", + "type": "date", + "sample": "2026-09-10" + }, + { + "name": "end_date", + "wire": "to", + "type": "date", + "sample": "2026-09-11" + } + ] + }, + { + "group": "sec", + "method": "get_sic_codes", + "endpoint": "SIC_LIST", + "source": "https://site.financialmodelingprep.com/developer/docs/stable/industry-classification-list", + "args": [], + "baseline_request": { + "path": "/stable/standard-industrial-classification-list", + "query": {} + }, + "baseline_parameters": [], + "baseline_annotations": {}, + "return_annotation": "list[SICCode]", + "new_parameters": [ + { + "name": "industry_title", + "wire": "industryTitle", + "type": "str", + "sample": "SERVICES" + }, + { + "name": "sic_code", + "wire": "sicCode", + "type": "str", + "sample": "7371" + } + ] + } + ] +} diff --git a/tests/integration/test_market.py b/tests/integration/test_market.py index a24d2f6b..cb5814fd 100644 --- a/tests/integration/test_market.py +++ b/tests/integration/test_market.py @@ -341,7 +341,7 @@ def test_search_exchange_variants( """Test exchange variants search""" with vcr_instance.use_cassette("market/search_exchange_variants.yaml"): results = self._handle_rate_limit( - fmp_client.market.search_exchange_variants, "Apple" + fmp_client.market.search_exchange_variants, "MSFT" ) assert isinstance(results, list) assert len(results) > 0 diff --git a/tests/unit/lc/test_endpoint_method_coverage.py b/tests/unit/lc/test_endpoint_method_coverage.py index 03ddec51..d5e6874b 100644 --- a/tests/unit/lc/test_endpoint_method_coverage.py +++ b/tests/unit/lc/test_endpoint_method_coverage.py @@ -24,8 +24,8 @@ tool schema. The dropped-mandatory allowlist is empty; a new dropped-mandatory is a PR failure. -Optional wire fields dropped under method dispatch are intentionally out of -scope here (they are not required for a successful call). MCP registers the +Optional wire fields are also covered: losing a filter can silently change +the requested data. MCP registers the live Python method via :func:`fmp_data.tool_binding.resolve_attr` and does not run this shape gate, so the two integrations *could* disagree on an allowlisted fallback — but as of #188 nothing is allowlisted, so both @@ -90,6 +90,11 @@ # dropped-mandatory is a PR failure. KNOWN_DROPPED_MANDATORY_WIRE: frozenset[tuple[str, str, str]] = frozenset() +# The catalog uses full-capability methods. Legacy convenience adapters such as +# get_insider_trades and search_cik_by_name are not catalog entries; their fixed +# or intentionally narrower queries must not excuse omissions in advertised tools. +KNOWN_DROPPED_OPTIONAL_WIRE: frozenset[tuple[str, str, str]] = frozenset() + def _catalog() -> list[tuple[str, Endpoint[Any], Any]]: """Every (label, endpoint, semantics) triple with resolvable semantics.""" @@ -139,6 +144,50 @@ def test_every_semantics_method_resolves(live_client: FMPDataClient) -> None: assert not missing, "Unresolvable semantics method names:\n" + "\n".join(missing) +def test_optional_wire_fields_are_not_silently_dropped(live_client): + actual = set() + catalog = _catalog() + assert len(catalog) >= _MIN_PAIRS + for _label, endpoint, semantics in catalog: + method = resolve_client_method( + live_client, semantics.client_name, semantics.method_name + ) + assert method is not None + names = set(bindable_params(method)) + for param in endpoint.optional_params or []: + if resolve_method_param_name(param.name, names) is None: + actual.add((semantics.client_name, semantics.method_name, param.name)) + assert actual == set(KNOWN_DROPPED_OPTIONAL_WIRE), ( + f"Optional tool parameters dropped or stale exceptions: {actual}" + ) + + +def test_optional_coverage_guard_detects_an_added_unbound_filter( + live_client, monkeypatch +): + catalog = _catalog() + label, endpoint, semantics = catalog[0] + mutated = endpoint.model_copy( + update={ + "optional_params": [ + *(endpoint.optional_params or []), + EndpointParam( + name="unbound_provider_filter", + location=ParamLocation.QUERY, + param_type=ParamType.STRING, + description="Deliberate schema drift to prove the guard fails", + ), + ] + } + ) + monkeypatch.setattr( + "tests.unit.lc.test_endpoint_method_coverage._catalog", + lambda: [(label, mutated, semantics), *catalog[1:]], + ) + with pytest.raises(AssertionError, match="unbound_provider_filter"): + test_optional_wire_fields_are_not_silently_dropped(live_client) + + def test_request_fallback_set_is_exactly_the_known_mismatches( live_client: FMPDataClient, ) -> None: diff --git a/tests/unit/lc/test_provider_optional_tools.py b/tests/unit/lc/test_provider_optional_tools.py new file mode 100644 index 00000000..9deb82a0 --- /dev/null +++ b/tests/unit/lc/test_provider_optional_tools.py @@ -0,0 +1,83 @@ +"""Exercise actual LangChain schemas and dispatch for provider filters.""" + +import importlib +from typing import Any + +import httpx +from langchain_core.embeddings import FakeEmbeddings +from langchain_core.tools import StructuredTool +from pydantic import BaseModel +import pytest + +from fmp_data.lc.models import EndpointInfo +from fmp_data.lc.registry import EndpointRegistry +from fmp_data.lc.vector_store import EndpointVectorStore +from fmp_data.tool_binding import resolve_method_param_name +from tests.unit.test_provider_optional_parameters import CASES, IDS, _transport + +# These methods were never in the tool catalog. Keep the inventory stable; +# their SDK signatures and HTTP queries are covered in the core regression suite. +SDK_ONLY = { + ("market", "get_company_screener"), + ("market", "get_available_exchanges"), + ("company", "get_earnings"), +} + + +@pytest.mark.parametrize("case", CASES, ids=IDS) +@pytest.mark.parametrize("mode", ["omitted", "none", "value"]) +def test_native_langchain_tool_preserves_filters(case, mode, fmp_client, tmp_path): + module = importlib.import_module(f"fmp_data.{case['group']}.mapping") + table = getattr(module, f"{case['group'].upper()}_ENDPOINTS_SEMANTICS") + matches = [s for s in table.values() if s.method_name == case["method"]] + if (case["group"], case["method"]) in SDK_ONLY: + assert matches == [] + return + assert matches + endpoint = getattr( + importlib.import_module(f"fmp_data.{case['group']}.endpoints"), case["endpoint"] + ) + optional_by_wire = {p.alias or p.name: p for p in endpoint.optional_params} + captured: list[dict[str, Any]] = [] + fmp_client.client.close() + fmp_client.client = httpx.Client(transport=_transport(case, captured)) + store = EndpointVectorStore( + client=fmp_client, + registry=EndpointRegistry(), + embeddings=FakeEmbeddings(size=8), + cache_dir=str(tmp_path), + ) + for semantics in matches: + tool = store.create_tool(EndpointInfo(endpoint=endpoint, semantics=semantics)) + assert isinstance(tool, StructuredTool) + model = tool.get_input_schema() + assert issubclass(model, BaseModel) + schema = model.model_json_schema() + required_args = dict( + zip( + [p["name"] for p in case["baseline_parameters"]], + case["args"], + strict=False, + ) + ) + params = {} + for param in endpoint.mandatory_params: + name = resolve_method_param_name(param.name, set(required_args)) + if name is not None: + params[param.name] = required_args[name] + expected = dict(case["baseline_request"]["query"]) + for new in case["new_parameters"]: + param = optional_by_wire[new["wire"]] + assert param.name in schema["properties"] + assert param.name not in schema.get("required", []) + assert param.name in semantics.parameter_hints + if mode != "omitted": + params[param.name] = new["sample"] if mode == "value" else None + if mode == "value": + value = new["sample"] + expected[new["wire"]] = ( + str(value).lower() if isinstance(value, bool) else str(value) + ) + result = tool.invoke(params) + assert result["status"] == "success", result + assert captured[-1]["query"] == expected diff --git a/tests/unit/test_market.py b/tests/unit/test_market.py index 1e6f5dec..10b18ac2 100644 --- a/tests/unit/test_market.py +++ b/tests/unit/test_market.py @@ -213,12 +213,12 @@ def test_search_exchange_variants( status_code=200, json_data=[search_result_data] ) - results = fmp_client.market.search_exchange_variants("Apple") + results = fmp_client.market.search_exchange_variants("MSFT") assert len(results) == 1 assert isinstance(results[0], CompanySearchResult) params = mock_request.call_args[1]["params"] - assert params["query"] == "Apple" + assert params["symbol"] == "MSFT" @patch("httpx.Client.request") def test_search_by_cik(self, mock_request, fmp_client, mock_response): diff --git a/tests/unit/test_mcp_optional_parameters.py b/tests/unit/test_mcp_optional_parameters.py new file mode 100644 index 00000000..6e653acc --- /dev/null +++ b/tests/unit/test_mcp_optional_parameters.py @@ -0,0 +1,52 @@ +"""Native MCP schema generation and invocation retain new optional filters.""" + +import importlib +import inspect +from typing import Any + +import httpx +import pytest + +pytest.importorskip("mcp", reason="MCP extra not installed") + +from fmp_data.mcp._compat import import_mcp_server_class +from fmp_data.mcp.tool_loader import register_from_manifest +from tests.unit.test_provider_optional_parameters import CASES, IDS, _transport + + +@pytest.mark.parametrize("case", CASES, ids=IDS) +@pytest.mark.parametrize("mode", ["omitted", "none", "value"]) +@pytest.mark.asyncio +async def test_native_mcp_schema_and_query(case, mode, fmp_client): + captured: list[dict[str, Any]] = [] + fmp_client.client.close() + fmp_client.client = httpx.Client(transport=_transport(case, captured)) + server = import_mcp_server_class()("parameter-coverage") + method = getattr(getattr(fmp_client, case["group"]), case["method"]) + module = importlib.import_module(f"fmp_data.{case['group']}.mapping") + table = getattr(module, f"{case['group'].upper()}_ENDPOINTS_SEMANTICS") + keys = [key for key, sem in table.items() if sem.method_name == case["method"]] + if keys: + register_from_manifest(server, fmp_client, [f"{case['group']}.{keys[0]}"]) + else: + # Existing SDK-only methods can be explicitly wrapped by applications. + server.add_tool(method) + tools = await server.list_tools() + assert len(tools) == 1 + schema = tools[0].model_dump(by_alias=True)["inputSchema"] + params = dict(zip(inspect.signature(method).parameters, case["args"], strict=False)) + expected = dict(case["baseline_request"]["query"]) + for new in case["new_parameters"]: + assert new["name"] in schema["properties"] + assert new["name"] not in schema.get("required", []) + assert schema["properties"][new["name"]]["default"] is None + if mode != "omitted": + params[new["name"]] = new["sample"] if mode == "value" else None + if mode == "value": + value = new["sample"] + expected[new["wire"]] = ( + str(value).lower() if isinstance(value, bool) else str(value) + ) + await server.call_tool(tools[0].name, params) + assert len(captured) == 1 + assert captured[0]["query"] == expected diff --git a/tests/unit/test_provider_optional_parameters.py b/tests/unit/test_provider_optional_parameters.py new file mode 100644 index 00000000..829b9843 --- /dev/null +++ b/tests/unit/test_provider_optional_parameters.py @@ -0,0 +1,227 @@ +"""Provider-derived expectations plus pre-change signatures and HTTP requests. + +The fixture was captured from FMP's documentation and dev at 02d2a90 on +2026-09-13, before changing the SDK. Do not regenerate it from endpoint metadata: +that would let a missing parameter disappear from both sides of the assertion. +""" + +import ast +from datetime import date +import importlib +import inspect +import json +from pathlib import Path +from typing import Any, get_type_hints + +import httpx +import pytest + +from fmp_data import AsyncFMPDataClient, FMPDataClient +from fmp_data.tool_binding import resolve_method_param_name + +CASES = json.loads( + ( + Path(__file__).parents[1] / "fixtures/provider_optional_parameters.json" + ).read_text() +)["cases"] +IDS = [f"{case['group']}.{case['method']}" for case in CASES] + + +def test_provider_fixture_retains_all_audited_findings(): + assert len(CASES) == len(set(IDS)) == 27 + assert sum(len(case["new_parameters"]) for case in CASES) == 74 + + +def _sample(param): + value = param["sample"] + return date.fromisoformat(value) if param["type"] == "date" else value + + +def _transport(case, captured): + def respond(request): + captured.append( + { + "path": request.url.path, + "query": {k: v for k, v in request.url.params.items() if k != "apikey"}, + } + ) + payload = [] + if case["method"] == "get_market_hours": + payload = [ + { + "exchange": "NYSE", + "name": "New York Stock Exchange", + "openingHour": "09:30", + "closingHour": "16:00", + "timezone": "America/New_York", + "isMarketOpen": True, + } + ] + return httpx.Response(200, json=payload) + + return httpx.MockTransport(respond) + + +@pytest.mark.parametrize("case", CASES, ids=IDS) +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +def test_additions_preserve_signature(case, asynchronous, client_config): + cls = AsyncFMPDataClient if asynchronous else FMPDataClient + client = cls( + config=client_config.model_copy( + update={"base_url": "https://test.financialmodelingprep.com"} + ) + ) + try: + method = getattr(getattr(client, case["group"]), case["method"]) + signature = inspect.signature(method) + old = case["baseline_parameters"] + assert list(signature.parameters) == [p["name"] for p in old] + [ + p["name"] for p in case["new_parameters"] + ] + for baseline in old: + param = signature.parameters[baseline["name"]] + assert param.kind.name == baseline["kind"] + assert repr(param.default) == baseline["default"] + for new in case["new_parameters"]: + param = signature.parameters[new["name"]] + assert param.kind is inspect.Parameter.KEYWORD_ONLY + assert param.default is None + python_type = { + "date": date, + "int": int, + "float": float, + "str": str, + "bool": bool, + }[new["type"]] + assert get_type_hints(method)[new["name"]] == python_type | None + source = inspect.getsourcefile(method) + assert source is not None + tree = ast.parse(Path(source).read_text()) + node = next( + n + for n in ast.walk(tree) + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) + and n.name == case["method"] + ) + # Async modules use future annotations but preserve the same public types. + assert node.returns is not None + assert ast.unparse(node.returns) == case["return_annotation"] + assert { + arg.arg: ast.unparse(arg.annotation) if arg.annotation else None + for arg in node.args.args + if arg.arg != "self" + } == case["baseline_annotations"] + finally: + client.close() + + +@pytest.mark.parametrize("case", CASES, ids=IDS) +@pytest.mark.parametrize("mode", ["omitted", "positional", "none", "value"]) +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +@pytest.mark.asyncio +async def test_actual_query_and_legacy_defaults( + case, mode, asynchronous, client_config +): + captured: list[dict[str, Any]] = [] + cls = AsyncFMPDataClient if asynchronous else FMPDataClient + client = cls( + config=client_config.model_copy( + update={"base_url": "https://test.financialmodelingprep.com"} + ) + ) + transport = _transport(case, captured) + if asynchronous: + client._async_client = httpx.AsyncClient(transport=transport) + else: + client.client.close() + client.client = httpx.Client(transport=transport) + kwargs = ( + {} + if mode in {"omitted", "positional"} + else { + p["name"]: _sample(p) if mode == "value" else None + for p in case["new_parameters"] + } + ) + try: + method = getattr(getattr(client, case["group"]), case["method"]) + args = list(case["args"]) + if mode == "positional": + args.extend( + ast.literal_eval(p["default"]) + for p in case["baseline_parameters"][len(args) :] + ) + result = method(*args, **kwargs) + if asynchronous: + await result + finally: + if asynchronous: + await client.aclose() + else: + client.close() + expected = dict(case["baseline_request"]["query"]) + if mode == "value": + for param in case["new_parameters"]: + value = param["sample"] + expected[param["wire"]] = ( + str(value).lower() if isinstance(value, bool) else str(value) + ) + assert captured == [{"path": case["baseline_request"]["path"], "query": expected}] + + +@pytest.mark.parametrize("case", CASES, ids=IDS) +def test_provider_parameters_reach_metadata_and_binding(case, fmp_client): + module = importlib.import_module(f"fmp_data.{case['group']}.endpoints") + endpoint = getattr(module, case["endpoint"]) + optional_by_wire = {p.alias or p.name: p for p in endpoint.optional_params} + method = getattr(getattr(fmp_client, case["group"]), case["method"]) + for expected in case["new_parameters"]: + param = optional_by_wire[expected["wire"]] + assert not param.required + assert param.description + assert ( + param.param_type.value + == { + "date": "date", + "int": "integer", + "float": "float", + "str": "string", + "bool": "boolean", + }[expected["type"]] + ) + assert ( + resolve_method_param_name( + param.name, set(inspect.signature(method).parameters) + ) + == expected["name"] + ) + + +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +@pytest.mark.asyncio +async def test_exchange_variants_uses_symbol_without_renaming_query( + asynchronous, client_config +): + captured: list[dict[str, Any]] = [] + client = (AsyncFMPDataClient if asynchronous else FMPDataClient)( + config=client_config + ) + transport = _transport({"method": "search_exchange_variants"}, captured) + if asynchronous: + client._async_client = httpx.AsyncClient(transport=transport) + else: + client.client.close() + client.client = httpx.Client(transport=transport) + try: + method = client.market.search_exchange_variants + assert list(inspect.signature(method).parameters) == ["query"] + result = method("MSFT") + if asynchronous: + assert inspect.isawaitable(result) + await result + finally: + if asynchronous: + await client.aclose() + else: + client.close() + assert captured[0]["query"] == {"symbol": "MSFT"} diff --git a/tests/unit/test_setup_uv_pin.py b/tests/unit/test_setup_uv_pin.py index 41ca8e16..d66c1d93 100644 --- a/tests/unit/test_setup_uv_pin.py +++ b/tests/unit/test_setup_uv_pin.py @@ -1,4 +1,4 @@ -"""setup-uv is SHA-pinned at v10 and installs uv 0.12.3.""" +"""setup-uv is SHA-pinned at v10 and installs uv 0.12.13.""" from __future__ import annotations @@ -21,8 +21,8 @@ def test_setup_uv_is_v10_and_tracks_latest_uv() -> None: assert len(pin) == 40 assert all(char in "0123456789abcdef" for char in pin) assert "v9.0.0" not in line - assert "v10.0.1" in line - assert 'version: "0.12.3"' in text + assert "v10.1.0" in line + assert 'version: "0.12.13"' in text assert seen >= 1 @@ -34,4 +34,4 @@ def test_pyproject_requires_current_uv() -> None: .joinpath("pyproject.toml") .read_text(encoding="utf-8") ) - assert 'required-version = ">=0.12.3"' in text + assert 'required-version = ">=0.12.13"' in text