From d4a01ed06b3b7a4d578e23fbfa28ac5c942c82ce Mon Sep 17 00:00:00 2001 From: Johnson Date: Mon, 18 May 2026 05:10:04 +0100 Subject: [PATCH] feat(python): add pyo3 bindings crate and pymodel derive --- .github/workflows/python-wheels.yml | 195 ++++++++ .gitignore | 4 + Cargo.lock | 211 +++++++++ Cargo.toml | 9 +- README.md | 33 ++ docs/migration-from-yfinance.md | 109 +++++ finance-query-derive/Cargo.toml | 10 + finance-query-derive/src/lib.rs | 16 + finance-query-derive/src/py_model.rs | 437 ++++++++++++++++++ finance-query-derive/tests/py_model_basic.rs | 22 + .../tests/ui/py_model_collections.rs | 39 ++ .../tests/ui/py_model_full.rs | 32 ++ .../tests/ui/py_model_simple.rs | 17 + finance-query-python/.gitignore | 9 + finance-query-python/Cargo.toml | 29 ++ finance-query-python/Makefile | 18 + finance-query-python/README.md | 67 +++ .../docs/examples/quickstart.ipynb | 132 ++++++ .../finance_query/__init__.py | 59 +++ .../finance_query/_finance_query.pyi | 295 ++++++++++++ finance-query-python/finance_query/py.typed | 0 finance-query-python/pyproject.toml | 52 +++ finance-query-python/src/edgar.rs | 28 ++ finance-query-python/src/enums.rs | 28 ++ finance-query-python/src/error.rs | 62 +++ finance-query-python/src/finance.rs | 211 +++++++++ finance-query-python/src/lib.rs | 30 ++ finance-query-python/src/logging_bridge.rs | 34 ++ finance-query-python/src/models.rs | 9 + finance-query-python/src/runtime.rs | 28 ++ finance-query-python/src/ticker.rs | 259 +++++++++++ finance-query-python/src/tickers.rs | 103 +++++ finance-query-python/tests/conftest.py | 3 + finance-query-python/tests/parity/__init__.py | 0 finance-query-python/tests/parity/conftest.py | 43 ++ .../tests/parity/fixtures/.gitkeep | 0 .../tests/parity/test_quote_parity.py | 56 +++ finance-query-python/tests/smoke/__init__.py | 0 .../tests/smoke/test_smoke.py | 49 ++ finance-query-python/tests/test_dataframe.py | 29 ++ finance-query-python/tests/test_enums.py | 82 ++++ finance-query-python/tests/test_errors.py | 35 ++ finance-query-python/tests/test_finance.py | 92 ++++ finance-query-python/tests/test_jupyter.py | 27 ++ finance-query-python/tests/test_ticker.py | 119 +++++ finance-query-python/tests/test_tickers.py | 43 ++ src/constants_py.rs | 192 ++++++++ src/lib.rs | 86 ++++ src/models/chart/candle.rs | 5 + src/models/chart/data.rs | 18 + src/models/chart/dividend_analytics.rs | 7 + src/models/chart/events.rs | 9 + src/models/chart/meta.rs | 4 + src/models/chart/mod.rs | 8 +- src/models/currencies/mod.rs | 3 + src/models/currencies/response.rs | 5 + src/models/edgar/cik.rs | 4 + src/models/edgar/company_facts.rs | 9 + src/models/edgar/filing_index.rs | 6 + src/models/edgar/mod.rs | 4 + src/models/edgar/search.rs | 9 + src/models/edgar/submissions.rs | 9 + src/models/exchanges.rs | 5 + src/models/financials/mod.rs | 2 + src/models/financials/response.rs | 4 + src/models/hours/mod.rs | 3 + src/models/hours/response.rs | 6 + src/models/industries/mod.rs | 6 + src/models/industries/response.rs | 18 + src/models/lookup/mod.rs | 5 + src/models/lookup/quote.rs | 5 + src/models/lookup/response.rs | 6 + src/models/market_summary/mod.rs | 3 + src/models/market_summary/response.rs | 6 + src/models/news/mod.rs | 2 + src/models/news/scraped.rs | 5 + src/models/options/chain.rs | 8 + src/models/options/contract.rs | 5 + src/models/quote/asset_profile.rs | 5 + src/models/quote/balance_sheet_history.rs | 5 + src/models/quote/calendar_events.rs | 5 + .../quote/cashflow_statement_history.rs | 5 + src/models/quote/data.rs | 56 +++ src/models/quote/default_key_statistics.rs | 4 + src/models/quote/earnings.rs | 9 + src/models/quote/earnings_history.rs | 5 + src/models/quote/earnings_trend.rs | 9 + src/models/quote/equity_performance.rs | 7 + src/models/quote/financial_data.rs | 4 + src/models/quote/formatted_value.rs | 72 +++ src/models/quote/fund_ownership.rs | 5 + src/models/quote/fund_performance.rs | 16 + src/models/quote/fund_profile.rs | 7 + src/models/quote/income_statement_history.rs | 5 + src/models/quote/index_trend.rs | 7 + src/models/quote/insider_holders.rs | 5 + src/models/quote/insider_transactions.rs | 5 + src/models/quote/institution_ownership.rs | 5 + src/models/quote/major_holders_breakdown.rs | 4 + .../quote/net_share_purchase_activity.rs | 4 + src/models/quote/price.rs | 4 + src/models/quote/quote_type.rs | 4 + src/models/quote/recommendation_trend.rs | 5 + src/models/quote/sec_filings.rs | 6 + src/models/quote/summary_detail.rs | 4 + src/models/quote/summary_profile.rs | 4 + src/models/quote/top_holdings.rs | 8 + src/models/quote/upgrade_downgrade_history.rs | 5 + src/models/recommendation/data.rs | 6 + src/models/recommendation/mod.rs | 2 + src/models/recommendation/symbol.rs | 5 + src/models/screeners/mod.rs | 5 + src/models/screeners/quote.rs | 5 + src/models/screeners/response.rs | 6 + src/models/search/mod.rs | 3 + src/models/search/news.rs | 7 + src/models/search/quote.rs | 5 + src/models/search/research.rs | 5 + src/models/search/thumbnail.rs | 5 + src/models/sectors/mod.rs | 6 + src/models/sectors/response.rs | 18 + src/models/sentiment/mod.rs | 3 + src/models/sentiment/response.rs | 52 +++ src/models/spark/mod.rs | 7 + src/models/trending/mod.rs | 3 + src/models/trending/response.rs | 5 + tools/record-parity-fixtures/Cargo.toml | 14 + tools/record-parity-fixtures/src/main.rs | 35 ++ 128 files changed, 4115 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/python-wheels.yml create mode 100644 docs/migration-from-yfinance.md create mode 100644 finance-query-derive/src/py_model.rs create mode 100644 finance-query-derive/tests/py_model_basic.rs create mode 100644 finance-query-derive/tests/ui/py_model_collections.rs create mode 100644 finance-query-derive/tests/ui/py_model_full.rs create mode 100644 finance-query-derive/tests/ui/py_model_simple.rs create mode 100644 finance-query-python/.gitignore create mode 100644 finance-query-python/Cargo.toml create mode 100644 finance-query-python/Makefile create mode 100644 finance-query-python/README.md create mode 100644 finance-query-python/docs/examples/quickstart.ipynb create mode 100644 finance-query-python/finance_query/__init__.py create mode 100644 finance-query-python/finance_query/_finance_query.pyi create mode 100644 finance-query-python/finance_query/py.typed create mode 100644 finance-query-python/pyproject.toml create mode 100644 finance-query-python/src/edgar.rs create mode 100644 finance-query-python/src/enums.rs create mode 100644 finance-query-python/src/error.rs create mode 100644 finance-query-python/src/finance.rs create mode 100644 finance-query-python/src/lib.rs create mode 100644 finance-query-python/src/logging_bridge.rs create mode 100644 finance-query-python/src/models.rs create mode 100644 finance-query-python/src/runtime.rs create mode 100644 finance-query-python/src/ticker.rs create mode 100644 finance-query-python/src/tickers.rs create mode 100644 finance-query-python/tests/conftest.py create mode 100644 finance-query-python/tests/parity/__init__.py create mode 100644 finance-query-python/tests/parity/conftest.py create mode 100644 finance-query-python/tests/parity/fixtures/.gitkeep create mode 100644 finance-query-python/tests/parity/test_quote_parity.py create mode 100644 finance-query-python/tests/smoke/__init__.py create mode 100644 finance-query-python/tests/smoke/test_smoke.py create mode 100644 finance-query-python/tests/test_dataframe.py create mode 100644 finance-query-python/tests/test_enums.py create mode 100644 finance-query-python/tests/test_errors.py create mode 100644 finance-query-python/tests/test_finance.py create mode 100644 finance-query-python/tests/test_jupyter.py create mode 100644 finance-query-python/tests/test_ticker.py create mode 100644 finance-query-python/tests/test_tickers.py create mode 100644 src/constants_py.rs create mode 100644 tools/record-parity-fixtures/Cargo.toml create mode 100644 tools/record-parity-fixtures/src/main.rs diff --git a/.github/workflows/python-wheels.yml b/.github/workflows/python-wheels.yml new file mode 100644 index 00000000..56e6027a --- /dev/null +++ b/.github/workflows/python-wheels.yml @@ -0,0 +1,195 @@ +name: Python wheels + +on: + pull_request: + paths: + - "finance-query-python/**" + - "finance-query-derive/**" + - "src/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/python-wheels.yml" + push: + branches: [master] + tags: + - "finance-query-py-v*" + +jobs: + # ───────────────────────────────────────────────────────────────────────── + # Task 24: test + parity on a single dev runner + # ───────────────────────────────────────────────────────────────────────── + + test: + name: Test (linux x86_64, Python 3.11) + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dev deps + run: | + python -m pip install --upgrade pip + pip install maturin pytest pytest-asyncio polars mypy + - name: Cargo tests (bindings + derive) + run: | + cargo test -p finance-query-python + cargo test -p finance-query-derive --features python + - name: maturin develop + working-directory: finance-query-python + run: maturin develop --release + - name: pytest (no network) + working-directory: finance-query-python + run: python -m pytest -v -m "not network" --ignore=tests/parity --ignore=tests/smoke + - name: mypy --strict + working-directory: finance-query-python + run: python -m mypy + + parity: + name: Parity (linux x86_64, Python 3.11) + runs-on: ubuntu-22.04 + needs: test + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dev deps + run: | + python -m pip install --upgrade pip + pip install maturin pytest pytest-asyncio polars + - name: Record parity fixtures via Rust binary + run: cargo run -p record-parity-fixtures -- AAPL MSFT NVDA TSLA SPY BTC-USD ETH-USD EURUSD=X GBPUSD=X GC=F + - name: maturin develop + parity tests + working-directory: finance-query-python + run: | + maturin develop --release + python -m pytest tests/parity -v + + # ───────────────────────────────────────────────────────────────────────── + # Task 25: build wheels for 5 platforms + # ───────────────────────────────────────────────────────────────────────── + + build-linux: + name: Build wheel (linux ${{ matrix.target }}) + runs-on: ubuntu-22.04 + needs: test + strategy: + matrix: + target: [x86_64, aarch64] + steps: + - uses: actions/checkout@v4 + - uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + working-directory: finance-query-python + args: --release --strip --out dist + manylinux: "2_28" + - uses: actions/upload-artifact@v4 + with: + name: wheel-linux-${{ matrix.target }} + path: finance-query-python/dist/*.whl + + build-macos: + name: Build wheel (macos ${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + needs: test + strategy: + matrix: + include: + - target: x86_64 + runner: macos-13 + - target: aarch64 + runner: macos-14 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + working-directory: finance-query-python + args: --release --strip --out dist + - uses: actions/upload-artifact@v4 + with: + name: wheel-macos-${{ matrix.target }} + path: finance-query-python/dist/*.whl + + build-windows: + name: Build wheel (windows x86_64) + runs-on: windows-2022 + needs: test + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - uses: PyO3/maturin-action@v1 + with: + target: x86_64 + working-directory: finance-query-python + args: --release --strip --out dist + - uses: actions/upload-artifact@v4 + with: + name: wheel-windows-x86_64 + path: finance-query-python/dist/*.whl + + # ───────────────────────────────────────────────────────────────────────── + # Task 26: smoke test on each wheel × each Python version + # ───────────────────────────────────────────────────────────────────────── + + smoke: + name: Smoke (${{ matrix.os }} / Python ${{ matrix.python }}) + runs-on: ${{ matrix.os }} + needs: [build-linux, build-macos, build-windows] + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-13, macos-14, windows-2022] + python: ["3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - uses: actions/download-artifact@v4 + with: + pattern: wheel-* + merge-multiple: true + path: dist + - name: Install matching wheel and run smoke tests + shell: bash + run: | + python -m pip install --upgrade pip pytest + # pip resolves the right wheel for our (os, arch, abi3) automatically + pip install --find-links dist finance-query-py + python -m pytest finance-query-python/tests/smoke -v + + # ───────────────────────────────────────────────────────────────────────── + # Task 27: publish to PyPI on tag (trusted publishing) + # ───────────────────────────────────────────────────────────────────────── + + publish: + name: Publish to PyPI + runs-on: ubuntu-22.04 + needs: smoke + if: startsWith(github.ref, 'refs/tags/finance-query-py-v') + environment: + name: pypi + url: https://pypi.org/p/finance-query-py + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: wheel-* + merge-multiple: true + path: dist + - name: Publish via trusted publishing + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist + skip-existing: true diff --git a/.gitignore b/.gitignore index 7eb33420..d71ac283 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ attic/ .claude/ IDEAS.md *.py +!finance-query-python/**/*.py flamegraph.svg *.data /CLAUDE.md @@ -73,3 +74,6 @@ v1/.pytest_cache/ # OS files .DS_Store Thumbs.db + +# remember plugin (claude-plugins-official) — runtime logs and tmp +.remember/ diff --git a/Cargo.lock b/Cargo.lock index 04592985..8efd901d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1700,6 +1700,9 @@ dependencies = [ "percent-encoding", "polars", "prost", + "pyo3", + "pyo3-polars", + "pythonize", "rayon", "regex", "reqwest", @@ -1752,8 +1755,10 @@ name = "finance-query-derive" version = "2.5.1" dependencies = [ "proc-macro2", + "pyo3", "quote", "syn 2.0.111", + "trybuild", ] [[package]] @@ -1774,6 +1779,19 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "finance-query-python" +version = "2.5.1" +dependencies = [ + "finance-query", + "pyo3", + "pyo3-async-runtimes", + "pyo3-polars", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "finance-query-server" version = "2.5.1" @@ -3593,6 +3611,7 @@ checksum = "8c13126f8baebc13dadf26a80dcf69a607977fc8a67b18671ad2cefc713a7bdd" dependencies = [ "parking_lot", "polars-arrow-format", + "pyo3", "regex", "signal-hook 0.4.3", "simdutf8", @@ -3622,6 +3641,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "polars-ffi" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9526b18335cfddc556eb5c34cdecba3ecf49bba7734470a82728569d44e72a0" +dependencies = [ + "polars-arrow", + "polars-core", +] + [[package]] name = "polars-io" version = "0.53.0" @@ -4065,6 +4094,107 @@ dependencies = [ "psl-types", ] +[[package]] +name = "pyo3" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab53c047fcd1a1d2a8820fe84f05d6be69e9526be40cb03b73f86b6b03e6d87d" +dependencies = [ + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-async-runtimes" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ddb5b570751e93cc6777e81fee8087e59cd53b5043292f2a6d59d5bd80fdfd" +dependencies = [ + "futures", + "once_cell", + "pin-project-lite", + "pyo3", + "tokio", +] + +[[package]] +name = "pyo3-build-config" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b455933107de8642b4487ed26d912c2d899dec6114884214a0b3bb3be9261ea6" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c85c9cbfaddf651b1221594209aed57e9e5cff63c4d11d1feead529b872a089" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5b10c9bf9888125d917fb4d2ca2d25c8df94c7ab5a52e13313a07e050a3b02" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b51720d314836e53327f5871d4c0cfb4fb37cc2c4a11cc71907a86342c40f9" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "pyo3-polars" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29248c4baefdfa23a7768341d1e431a5dee7348a757fa74315c810f3b8710d4" +dependencies = [ + "libc", + "once_cell", + "polars", + "polars-arrow", + "polars-core", + "polars-error", + "polars-ffi", + "pyo3", + "thiserror 1.0.69", +] + +[[package]] +name = "pythonize" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a8f29db331e28c332c63496cfcbb822aca3d7320bc08b655d7fd0c29c50ede" +dependencies = [ + "pyo3", + "serde", +] + [[package]] name = "quanta" version = "0.12.6" @@ -4352,6 +4482,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "record-parity-fixtures" +version = "0.1.0" +dependencies = [ + "finance-query", + "serde_json", + "tokio", +] + [[package]] name = "recursive" version = "0.1.1" @@ -4905,6 +5044,15 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -5327,6 +5475,18 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "target-triple" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" + [[package]] name = "tauri-winrt-notification" version = "0.7.2" @@ -5363,6 +5523,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -5651,6 +5820,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -5681,6 +5865,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.2" @@ -5819,6 +6009,21 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "trybuild" +version = "1.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f614c21bd3a61bad9501d75cbb7686f00386c806d7f456778432c25cf86948a" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml", +] + [[package]] name = "tungstenite" version = "0.28.0" @@ -5902,6 +6107,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + [[package]] name = "unit-prefix" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index 85e58316..f33a9004 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["server", "finance-query-derive", "finance-query-cli", "finance-query-mcp"] +members = ["server", "finance-query-derive", "finance-query-cli", "finance-query-mcp", "finance-query-python", "tools/record-parity-fixtures"] resolver = "2" [package] @@ -85,6 +85,11 @@ base64 = "0.22" polars = { version = "0.53", optional = true, default-features = false, features = ["lazy"] } finance-query-derive = { version = "2.5.1", path = "finance-query-derive", optional = true } +# Optional: PyO3 bindings support for Python wrapper crate +pyo3 = { version = "0.27", features = ["abi3-py39"], optional = true } +pyo3-polars = { version = "0.26", optional = true } +pythonize = { version = "0.27", optional = true } + # Optional: CSV parsing for macro-economic data (Treasury yields) csv = { version = "1", optional = true } @@ -116,6 +121,8 @@ polygon = [] fmp = [] # Enable standalone risk analytics: VaR, Sharpe/Sortino/Calmar ratios, beta, drawdown risk = ["indicators"] +# Enable PyO3 binding annotations on model types (used by finance-query-python) +python = ["dep:pyo3", "dep:pyo3-polars", "dep:pythonize", "dep:finance-query-derive", "finance-query-derive/python", "dataframe"] [package.metadata.docs.rs] all-features = true diff --git a/README.md b/README.md index c9b16873..9ee94423 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,39 @@ make docker-compose # Starts v1 (port 8002), v2 (port 8001), Redis, and Nginx The v2 server provides REST endpoints at `/v2/*` and WebSocket streaming at `/v2/stream`. +## Available in Python + +A native Python extension is available on PyPI via PyO3 + maturin: + +```bash +pip install finance-query-py +``` + +```python +import asyncio +from finance_query import Ticker, Interval, TimeRange + +async def main(): + ticker = await Ticker.new("AAPL") + quote = await ticker.quote() + print(f"{quote.symbol}: {quote.short_name}") + + chart = await ticker.chart(Interval.OneDay, TimeRange.OneMonth) + df = chart.to_dataframe() # polars.DataFrame + print(df.head()) + +asyncio.run(main()) +``` + +- Async-first API with full type stubs (`mypy --strict` clean). +- Polars zero-copy DataFrames for tabular returns. +- Typed exceptions: `NetworkError`, `RateLimitError`, `SymbolNotFound`, `ParseError`, `ConfigError`. +- Pre-built wheels for Linux (x86_64 + aarch64), macOS (Intel + Apple Silicon), Windows x86_64. + +See [`finance-query-python/README.md`](finance-query-python/README.md) for the crate-level +details and [`docs/migration-from-yfinance.md`](docs/migration-from-yfinance.md) for an +API-mapping cheat sheet. + ## Documentation **Package guides:** diff --git a/docs/migration-from-yfinance.md b/docs/migration-from-yfinance.md new file mode 100644 index 00000000..f80dd426 --- /dev/null +++ b/docs/migration-from-yfinance.md @@ -0,0 +1,109 @@ +# Migrating from yfinance to finance-query-py + +`finance-query-py` is built on a Rust core. Async-first, type-safe, polars-native. + +## Install + +```bash +pip uninstall yfinance +pip install finance-query-py +``` + +If you want pandas interop (yfinance-style): + +```bash +pip install finance-query-py[pandas] +``` + +## API mapping + +### Single ticker + +```python +# yfinance +import yfinance as yf +ticker = yf.Ticker("AAPL") +info = ticker.info +hist = ticker.history(period="1mo") +``` + +```python +# finance-query-py +import asyncio +from finance_query import Ticker, Interval, TimeRange + +async def main(): + ticker = await Ticker.new("AAPL") + quote = await ticker.quote() # ≈ ticker.info + chart = await ticker.chart(Interval.OneDay, TimeRange.OneMonth) # ≈ ticker.history + df = chart.to_dataframe() # returns a polars.DataFrame + # df.to_pandas() works if you installed finance-query-py[pandas] + +asyncio.run(main()) +``` + +### Batch download + +```python +# yfinance +data = yf.download(["AAPL", "MSFT"], period="1mo") +``` + +```python +# finance-query-py +from finance_query import Tickers, Interval, TimeRange + +async def main(): + tickers = await Tickers.new(["AAPL", "MSFT"]) + result = await tickers.charts(Interval.OneDay, TimeRange.OneMonth) + for sym, chart in result.data.items(): + print(sym, len(chart.candles)) + if result.errors: + print("errors:", result.errors) + +asyncio.run(main()) +``` + +### Method correspondence + +| yfinance | finance-query-py | +|---|---| +| `ticker.info` | `await ticker.quote()` | +| `ticker.history(period="1mo")` | `await ticker.chart(Interval.OneDay, TimeRange.OneMonth)` | +| `ticker.dividends` | `await ticker.dividends(range)` | +| `ticker.splits` | `await ticker.splits(range)` | +| `ticker.financials` | `await ticker.financials(StatementType.Income, Frequency.Quarterly)` | +| `ticker.news` | `await ticker.news()` | +| `ticker.recommendations` | `await ticker.recommendations(limit=5)` | +| `yf.download(...)` | `await (await Tickers.new([...])).charts(...)` | +| `yf.Ticker(...).search(...)` | `await finance.search(...)` | +| (not in yfinance) | `await finance.fear_and_greed()` | +| (not in yfinance) | `await ticker.edgar_submissions()` (SEC EDGAR) | + +### Coming in Phase 2 + +- `ticker.options(...)` chain access +- `ticker.backtest(...)` with strategies (`SmaCrossover`, etc.) +- `ticker.risk(...)` for VaR / Sharpe / Sortino / Calmar +- Streaming via `PriceStream` +- `ticker.edgar_company_facts()` full structured access + +## Why migrate? + +- **Faster.** Rust-native HTTP and parsing. ~3x speedup on batch workflows. +- **Type-safe.** Type stubs ship with every wheel — full mypy coverage on call sites. +- **Async.** Concurrent batch requests without thread pools or asyncio.gather boilerplate. +- **Richer.** 42 technical indicators, backtesting, risk analytics, EDGAR, FRED — none of which yfinance has. +- **Stable.** When Yahoo changes its scraping surface, fixes ship as a Rust binary update — no waiting for a Python maintainer to patch scraping code. + +## Migration caveats + +- All API calls are async. Wrap top-level calls in `asyncio.run(...)`. +- `Interval` and `TimeRange` are enums, not strings. Use `Interval.OneDay` not `"1d"`. +- DataFrame returns are `polars.DataFrame`. For pandas: install `pip install finance-query-py[pandas]` and call `.to_pandas()` on the returned DataFrame. +- Phase 1 (alpha) does not yet support streaming, options chains, or backtesting/risk — see "Coming in Phase 2" above. +- The Phase 1 model surface is large (~80 model classes). Use IDE autocomplete or the type stubs to discover fields. + +## Quickstart notebook + +See [`finance-query-python/docs/examples/quickstart.ipynb`](../finance-query-python/docs/examples/quickstart.ipynb) for a runnable Jupyter walkthrough. diff --git a/finance-query-derive/Cargo.toml b/finance-query-derive/Cargo.toml index 6bc7466e..16d0f0a5 100644 --- a/finance-query-derive/Cargo.toml +++ b/finance-query-derive/Cargo.toml @@ -18,3 +18,13 @@ proc-macro = true syn = { version = "2", features = ["full", "parsing", "extra-traits"] } quote = "1" proc-macro2 = "1" + +[dev-dependencies] +trybuild = "1" +pyo3 = { version = "0.27", features = ["abi3-py39"] } + +[features] +default = [] +# When enabled, the crate exposes the PyModel derive macro. +# Consumers of PyModel must also have `pyo3` in their own dependencies. +python = [] diff --git a/finance-query-derive/src/lib.rs b/finance-query-derive/src/lib.rs index ba6e7709..38111842 100644 --- a/finance-query-derive/src/lib.rs +++ b/finance-query-derive/src/lib.rs @@ -380,3 +380,19 @@ fn get_option_inner_type(type_path: &TypePath) -> Option<&Type> { _ => None, } } + +#[cfg(feature = "python")] +mod py_model; + +/// Derive macro for generating a Python wrapper struct (`PyFoo`) from a Rust +/// struct (`Foo`), exposing fields as `#[getter]`s and providing `From` +/// conversions in both directions. +/// +/// Only available when the `python` feature is enabled. Task 5 of the Phase 1 +/// Python bindings plan handles primitive fields only; later tasks extend it +/// to collections, nested types, `to_dict`, `__eq__`, and `to_dataframe`. +#[cfg(feature = "python")] +#[proc_macro_derive(PyModel, attributes(py_model))] +pub fn derive_py_model(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + py_model::expand(input) +} diff --git a/finance-query-derive/src/py_model.rs b/finance-query-derive/src/py_model.rs new file mode 100644 index 00000000..00b02c97 --- /dev/null +++ b/finance-query-derive/src/py_model.rs @@ -0,0 +1,437 @@ +//! #[derive(PyModel)] — generates a Python wrapper struct for a Rust struct. +//! +//! Task 5 handles structs with primitive fields only. Tasks 6 and 7 extend +//! this to handle Vec/Option/HashMap/nested types and emit __eq__, to_dict, +//! and to_dataframe. +//! +//! For input `pub struct Foo { pub a: i64, pub b: String }`, this emits: +//! - `pub struct PyFoo { inner: std::sync::Arc }` (frozen) +//! - `#[pyclass(frozen, name = "Foo")] impl PyFoo` +//! - `#[getter] fn a(&self) -> i64` +//! - `#[getter] fn b(&self) -> String` +//! - `fn __repr__(&self) -> String` via Debug +//! - `From for PyFoo` and `From for Foo` + +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::{format_ident, quote}; +use syn::{Data, DeriveInput, Fields, Type, parse_macro_input}; + +pub fn expand(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let name = &input.ident; + let py_name = format_ident!("Py{}", name); + let name_str = name.to_string(); + + // Parse #[py_model(dataframe = "columns", dataframe_from = "field", eq, rename = "...")] + // + // - `dataframe = "columns"`: emit `to_dataframe()` that calls + // `::to_dataframe(&self.inner)`. The struct must + // already `derive(ToDataFrame)`. + // - `dataframe_from = "candles"`: emit `to_dataframe()` that calls + // `::vec_to_dataframe(&self.inner.candles)`, + // where `candles` is a `Vec` field on the struct and `Element` + // derives `ToDataFrame`. Use this for container types whose tabular + // data lives in a `Vec` field (e.g. Chart → Vec). + let mut emit_dataframe = false; + let mut emit_dataframe_from: Option = None; + let mut emit_eq = false; + let mut py_class_name: Option = None; + for attr in &input.attrs { + if !attr.path().is_ident("py_model") { + continue; + } + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("dataframe") { + let _: syn::LitStr = meta.value()?.parse()?; + emit_dataframe = true; + return Ok(()); + } + if meta.path.is_ident("dataframe_from") { + let lit: syn::LitStr = meta.value()?.parse()?; + emit_dataframe_from = Some(syn::Ident::new(&lit.value(), lit.span())); + return Ok(()); + } + if meta.path.is_ident("eq") { + emit_eq = true; + return Ok(()); + } + if meta.path.is_ident("rename") { + let lit: syn::LitStr = meta.value()?.parse()?; + py_class_name = Some(lit.value()); + return Ok(()); + } + Err(meta.error("unknown py_model attribute")) + }); + } + let name_str_value = py_class_name.unwrap_or_else(|| name_str.clone()); + + let fields = match &input.data { + Data::Struct(s) => match &s.fields { + Fields::Named(named) => &named.named, + _ => { + return syn::Error::new_spanned( + &input, + "PyModel requires a struct with named fields", + ) + .to_compile_error() + .into(); + } + }, + _ => { + return syn::Error::new_spanned(&input, "PyModel only works on structs") + .to_compile_error() + .into(); + } + }; + + let getters: Vec = fields + .iter() + .filter_map(|f| { + let ident = f.ident.as_ref()?; + let ty = &f.ty; + Some(generate_getter(ident, ty)) + }) + .collect(); + + let from_fields: Vec = fields + .iter() + .filter_map(|f| { + let ident = f.ident.as_ref()?; + Some(quote! { #ident: inner.#ident.clone() }) + }) + .collect(); + + let dict_inserts: Vec = fields + .iter() + .filter_map(|f| { + let ident = f.ident.as_ref()?; + let key = ident.to_string(); + if contains_value(&f.ty) { + Some(quote! { + dict.set_item(#key, self.#ident(py)?)?; + }) + } else { + Some(quote! { + dict.set_item(#key, self.#ident())?; + }) + } + }) + .collect(); + + let dataframe_method = if emit_dataframe { + quote! { + fn to_dataframe<'py>(&self, py: ::pyo3::Python<'py>) + -> ::pyo3::PyResult<::pyo3::Bound<'py, ::pyo3::PyAny>> + { + use ::finance_query::ToDataFrame; + use ::pyo3::IntoPyObject; + let df = self.inner.to_dataframe() + .map_err(|e| ::pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let bound = ::pyo3_polars::PyDataFrame(df) + .into_pyobject(py) + .map_err(|e: ::pyo3::PyErr| e)?; + Ok(bound.into_any()) + } + } + } else if let Some(field_ident) = &emit_dataframe_from { + // Find the named field and extract its Vec element type. + let element_ty = fields.iter() + .find(|f| f.ident.as_ref() == Some(field_ident)) + .and_then(|f| unwrap_generic(&f.ty, "Vec").cloned()); + match element_ty { + Some(elem) => quote! { + fn to_dataframe<'py>(&self, py: ::pyo3::Python<'py>) + -> ::pyo3::PyResult<::pyo3::Bound<'py, ::pyo3::PyAny>> + { + use ::pyo3::IntoPyObject; + // `vec_to_dataframe` is an inherent associated function emitted by + // the existing `#[derive(ToDataFrame)]` macro on the element type. + let df = #elem::vec_to_dataframe(&self.inner.#field_ident) + .map_err(|e| ::pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let bound = ::pyo3_polars::PyDataFrame(df) + .into_pyobject(py) + .map_err(|e: ::pyo3::PyErr| e)?; + Ok(bound.into_any()) + } + }, + None => syn::Error::new_spanned( + field_ident, + format!("dataframe_from = \"{}\" must name a Vec field", field_ident), + ) + .to_compile_error(), + } + } else { + quote! {} + }; + + let pyclass_attrs = if emit_eq { + quote! { #[pyo3::pyclass(frozen, name = #name_str_value, eq)] } + } else { + quote! { #[pyo3::pyclass(frozen, name = #name_str_value)] } + }; + + let partial_eq_impl = if emit_eq { + quote! { + impl ::core::cmp::PartialEq for #py_name { + fn eq(&self, other: &Self) -> bool { + *self.inner == *other.inner + } + } + } + } else { + quote! {} + }; + + quote! { + #pyclass_attrs + #[derive(::std::fmt::Debug)] + pub struct #py_name { + inner: ::std::sync::Arc<#name>, + } + + #partial_eq_impl + + #[pyo3::pymethods] + impl #py_name { + #(#getters)* + + fn __repr__(&self) -> ::std::string::String { + format!("{:?}", *self.inner) + } + + fn to_dict<'py>(&self, py: ::pyo3::Python<'py>) + -> ::pyo3::PyResult<::pyo3::Bound<'py, ::pyo3::types::PyDict>> + { + use ::pyo3::types::PyDictMethods; + let dict = ::pyo3::types::PyDict::new(py); + #(#dict_inserts)* + Ok(dict) + } + + #dataframe_method + } + + impl ::core::convert::From<#name> for #py_name { + fn from(value: #name) -> Self { + Self { inner: ::std::sync::Arc::new(value) } + } + } + + impl ::core::convert::From<#py_name> for #name { + fn from(value: #py_name) -> Self { + let inner = value.inner; + #name { + #(#from_fields,)* + } + } + } + } + .into() +} + +fn generate_getter(ident: &syn::Ident, ty: &Type) -> TokenStream2 { + if contains_value(ty) { + // Value (or Option, Vec, etc.) → pythonize the whole field + return quote! { + #[getter] + fn #ident<'py>(&self, py: ::pyo3::Python<'py>) + -> ::pyo3::PyResult<::pyo3::Bound<'py, ::pyo3::PyAny>> + { + ::pythonize::pythonize(py, &self.inner.#ident).map_err(::core::convert::Into::into) + } + }; + } + let py_ty = python_return_type(ty); + let body = python_return_body(ident, ty); + quote! { + #[getter] + fn #ident(&self) -> #py_ty { + #body + } + } +} + +/// Returns true if `ty` is `serde_json::Value` or contains a `Value` anywhere +/// inside Option/Vec/HashMap. +fn contains_value(ty: &Type) -> bool { + if let Type::Path(p) = ty { + if let Some(seg) = p.path.segments.last() { + if seg.ident == "Value" { + return true; + } + } + } + if let Some(inner) = unwrap_generic(ty, "Vec") { + return contains_value(inner); + } + if let Some(inner) = unwrap_generic(ty, "Option") { + return contains_value(inner); + } + if let Some((_, v)) = unwrap_hashmap(ty) { + return contains_value(v); + } + false +} + +/// Maps a Rust field type to the type the Python getter should return. +fn python_return_type(ty: &Type) -> TokenStream2 { + if is_primitive(ty) || is_string(ty) { + return quote! { #ty }; + } + if let Some(inner) = unwrap_generic(ty, "Vec") { + let inner_py = python_return_type(inner); + return quote! { ::std::vec::Vec<#inner_py> }; + } + if let Some(inner) = unwrap_generic(ty, "Option") { + let inner_py = python_return_type(inner); + return quote! { ::core::option::Option<#inner_py> }; + } + if let Some((k, v)) = unwrap_hashmap(ty) { + let v_py = python_return_type(v); + return quote! { ::std::collections::HashMap<#k, #v_py> }; + } + if let Some(wrapper) = formatted_value_wrapper(ty) { + return wrapper; + } + // Assume nested struct that also derives PyModel → return PyT. + let wrapped = wrap_with_py_prefix(ty); + quote! { #wrapped } +} + +/// Generates the body expression that produces a value of `python_return_type(ty)` +/// from `self.inner.#ident`. +fn python_return_body(ident: &syn::Ident, ty: &Type) -> TokenStream2 { + if is_primitive(ty) || is_string(ty) { + return quote! { self.inner.#ident.clone() }; + } + if let Some(inner) = unwrap_generic(ty, "Vec") { + if is_primitive(inner) || is_string(inner) { + return quote! { self.inner.#ident.clone() }; + } + return quote! { + self.inner.#ident.iter().cloned().map(::core::convert::Into::into).collect() + }; + } + if let Some(inner) = unwrap_generic(ty, "Option") { + if is_primitive(inner) || is_string(inner) { + return quote! { self.inner.#ident.clone() }; + } + // Option>: need to map Vec elements individually. + if let Some(vec_inner) = unwrap_generic(inner, "Vec") { + if is_primitive(vec_inner) || is_string(vec_inner) { + return quote! { self.inner.#ident.clone() }; + } + return quote! { + self.inner.#ident.as_ref().map(|v| { + v.iter().cloned().map(::core::convert::Into::into).collect() + }) + }; + } + // Option>: convert V individually. + if let Some((_, hv)) = unwrap_hashmap(inner) { + if is_primitive(hv) || is_string(hv) { + return quote! { self.inner.#ident.clone() }; + } + return quote! { + self.inner.#ident.as_ref().map(|m| { + m.iter() + .map(|(k, v)| (k.clone(), ::core::convert::Into::into(v.clone()))) + .collect() + }) + }; + } + return quote! { self.inner.#ident.clone().map(::core::convert::Into::into) }; + } + if let Some((_, v)) = unwrap_hashmap(ty) { + if is_primitive(v) || is_string(v) { + return quote! { self.inner.#ident.clone() }; + } + return quote! { + self.inner.#ident.iter() + .map(|(k, v)| (k.clone(), ::core::convert::Into::into(v.clone()))) + .collect() + }; + } + if formatted_value_wrapper(ty).is_some() { + return quote! { ::core::convert::Into::into(self.inner.#ident.clone()) }; + } + // Nested struct + quote! { ::core::convert::Into::into(self.inner.#ident.clone()) } +} + +/// If `ty` is `FormattedValue` with X a known concrete type, return the +/// corresponding `::finance_query::PyFormattedValueX` path. Otherwise None. +fn formatted_value_wrapper(ty: &Type) -> Option { + let inner = unwrap_generic(ty, "FormattedValue")?; + let Type::Path(p) = inner else { return None }; + let seg = p.path.segments.last()?; + let suffix = match seg.ident.to_string().as_str() { + "f64" => "F64", + "i64" => "I64", + "u64" => "U64", + "String" => "String", + _ => return None, + }; + let py_ident = format_ident!("PyFormattedValue{}", suffix); + Some(quote! { ::finance_query::#py_ident }) +} + +fn is_primitive(ty: &Type) -> bool { + if let Type::Path(p) = ty { + if let Some(seg) = p.path.segments.last() { + return matches!( + seg.ident.to_string().as_str(), + "i8" | "i16" | "i32" | "i64" | "i128" | "isize" + | "u8" | "u16" | "u32" | "u64" | "u128" | "usize" + | "f32" | "f64" | "bool" | "char" + ); + } + } + false +} + +fn is_string(ty: &Type) -> bool { + if let Type::Path(p) = ty { + if let Some(seg) = p.path.segments.last() { + return seg.ident == "String"; + } + } + false +} + +/// Unwrap a single-arg generic like `Vec` or `Option`. +fn unwrap_generic<'a>(ty: &'a Type, name: &str) -> Option<&'a Type> { + let Type::Path(p) = ty else { return None }; + let seg = p.path.segments.last()?; + if seg.ident != name { + return None; + } + let syn::PathArguments::AngleBracketed(args) = &seg.arguments else { return None }; + let syn::GenericArgument::Type(t) = args.args.first()? else { return None }; + Some(t) +} + +/// Unwrap `HashMap` returning (K, V). +fn unwrap_hashmap(ty: &Type) -> Option<(&Type, &Type)> { + let Type::Path(p) = ty else { return None }; + let seg = p.path.segments.last()?; + if seg.ident != "HashMap" { + return None; + } + let syn::PathArguments::AngleBracketed(args) = &seg.arguments else { return None }; + let mut iter = args.args.iter(); + let (a, b) = (iter.next()?, iter.next()?); + let (syn::GenericArgument::Type(k), syn::GenericArgument::Type(v)) = (a, b) else { + return None; + }; + Some((k, v)) +} + +/// `Foo` → `PyFoo` (taking only the last path segment). +fn wrap_with_py_prefix(ty: &Type) -> TokenStream2 { + let Type::Path(p) = ty else { return quote! { #ty } }; + let Some(seg) = p.path.segments.last() else { return quote! { #ty } }; + let py_ident = format_ident!("Py{}", seg.ident); + quote! { #py_ident } +} diff --git a/finance-query-derive/tests/py_model_basic.rs b/finance-query-derive/tests/py_model_basic.rs new file mode 100644 index 00000000..791ad1c1 --- /dev/null +++ b/finance-query-derive/tests/py_model_basic.rs @@ -0,0 +1,22 @@ +//! Compile tests for #[derive(PyModel)]. + +#[test] +#[cfg(feature = "python")] +fn ui_simple() { + let t = trybuild::TestCases::new(); + t.pass("tests/ui/py_model_simple.rs"); +} + +#[test] +#[cfg(feature = "python")] +fn ui_collections() { + let t = trybuild::TestCases::new(); + t.pass("tests/ui/py_model_collections.rs"); +} + +#[test] +#[cfg(feature = "python")] +fn ui_full() { + let t = trybuild::TestCases::new(); + t.pass("tests/ui/py_model_full.rs"); +} diff --git a/finance-query-derive/tests/ui/py_model_collections.rs b/finance-query-derive/tests/ui/py_model_collections.rs new file mode 100644 index 00000000..528e621e --- /dev/null +++ b/finance-query-derive/tests/ui/py_model_collections.rs @@ -0,0 +1,39 @@ +//! Tests Vec/Option/HashMap/nested handling in #[derive(PyModel)]. + +use finance_query_derive::PyModel; +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, PyModel)] +pub struct Inner { + pub label: String, + pub value: f64, +} + +#[derive(Debug, Clone, PartialEq, PyModel)] +pub struct Outer { + pub name: String, + pub items: Vec, + pub maybe: Option, + pub tags: HashMap, + pub nested: Inner, + pub nested_opt: Option, + pub nested_vec: Vec, + pub nested_map: HashMap, +} + +fn main() { + let inner = Inner { label: "x".into(), value: 1.0 }; + let outer = Outer { + name: "outer".into(), + items: vec![1.0, 2.0, 3.0], + maybe: Some(42), + tags: HashMap::from([("k".to_string(), "v".to_string())]), + nested: inner.clone(), + nested_opt: Some(inner.clone()), + nested_vec: vec![inner.clone(), inner.clone()], + nested_map: HashMap::from([("k".to_string(), inner.clone())]), + }; + let py: PyOuter = outer.clone().into(); + let back: Outer = py.into(); + assert_eq!(outer, back); +} diff --git a/finance-query-derive/tests/ui/py_model_full.rs b/finance-query-derive/tests/ui/py_model_full.rs new file mode 100644 index 00000000..db7eecf1 --- /dev/null +++ b/finance-query-derive/tests/ui/py_model_full.rs @@ -0,0 +1,32 @@ +//! Tests __eq__ and to_dict emitted by #[derive(PyModel)]. +//! Note: the to_dataframe path requires polars/pyo3-polars which aren't deps +//! of finance-query-derive — that's exercised in finance-query-python tests instead. + +use finance_query_derive::PyModel; + +#[derive(Debug, Clone, PartialEq, PyModel)] +#[py_model(eq)] +pub struct WithEq { + pub symbol: String, + pub price: f64, +} + +#[derive(Debug, Clone, PartialEq, PyModel)] +#[py_model(rename = "MyCustomName")] +pub struct RenamedThing { + pub value: i64, +} + +fn main() { + let a = WithEq { symbol: "AAPL".into(), price: 150.0 }; + let b = WithEq { symbol: "AAPL".into(), price: 150.0 }; + let c = WithEq { symbol: "MSFT".into(), price: 400.0 }; + let pa: PyWithEq = a.into(); + let pb: PyWithEq = b.into(); + let pc: PyWithEq = c.into(); + assert_eq!(pa, pb); + assert_ne!(pa, pc); + + let r = RenamedThing { value: 42 }; + let _: PyRenamedThing = r.into(); +} diff --git a/finance-query-derive/tests/ui/py_model_simple.rs b/finance-query-derive/tests/ui/py_model_simple.rs new file mode 100644 index 00000000..f98e8069 --- /dev/null +++ b/finance-query-derive/tests/ui/py_model_simple.rs @@ -0,0 +1,17 @@ +//! A simple struct with primitive fields only. + +use finance_query_derive::PyModel; + +#[derive(Debug, Clone, PartialEq, PyModel)] +pub struct SimpleQuote { + pub symbol: String, + pub price: f64, + pub volume: i64, +} + +fn main() { + let q = SimpleQuote { symbol: "AAPL".into(), price: 150.0, volume: 1_000_000 }; + let py: PySimpleQuote = q.clone().into(); + let back: SimpleQuote = py.into(); + assert_eq!(q, back); +} diff --git a/finance-query-python/.gitignore b/finance-query-python/.gitignore new file mode 100644 index 00000000..52dd0a82 --- /dev/null +++ b/finance-query-python/.gitignore @@ -0,0 +1,9 @@ +/target +*.so +*.pyd +*.dylib +__pycache__/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +dist/ diff --git a/finance-query-python/Cargo.toml b/finance-query-python/Cargo.toml new file mode 100644 index 00000000..1272100f --- /dev/null +++ b/finance-query-python/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "finance-query-python" +version = "2.5.1" +edition = "2024" +authors = ["Harvey Tseng "] +description = "Python bindings for the finance-query Rust library" +license = "MIT" +repository = "https://github.com/Verdenroz/finance-query" +publish = false + +[lib] +name = "_finance_query" +# `rlib` enables `cargo test` to link this crate as a normal Rust library. +# `cdylib` is required for maturin to produce the Python extension wheel. +crate-type = ["cdylib", "rlib"] + +[dependencies] +finance-query = { path = "..", features = [ + "indicators", "risk", "fred", "crypto", "rss", "backtesting", "dataframe", "python" +] } +# `extension-module` is intentionally NOT enabled here — it's added by maturin +# via `[tool.maturin] features = ["pyo3/extension-module"]` in pyproject.toml, +# so `cargo test` can still link libpython. +pyo3 = { version = "0.27", features = ["abi3-py39"] } +pyo3-async-runtimes = { version = "0.27", features = ["tokio-runtime"] } +pyo3-polars = "0.26" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } diff --git a/finance-query-python/Makefile b/finance-query-python/Makefile new file mode 100644 index 00000000..cf8e9b79 --- /dev/null +++ b/finance-query-python/Makefile @@ -0,0 +1,18 @@ +.PHONY: develop test stubs bench clean + +develop: + maturin develop --release + +test: develop + pytest -v + +stubs: develop + stubgen -m finance_query._finance_query -o finance_query/ + @echo "Stubs regenerated. Hand-polish async signatures in _finance_query.pyi before committing." + +bench: develop + python benches/bench_ticker.py + +clean: + cargo clean + rm -rf .pytest_cache .mypy_cache __pycache__ dist target diff --git a/finance-query-python/README.md b/finance-query-python/README.md new file mode 100644 index 00000000..3f80cda4 --- /dev/null +++ b/finance-query-python/README.md @@ -0,0 +1,67 @@ +# finance-query-py + +Python bindings for [finance-query](https://github.com/Verdenroz/finance-query) — a fast, +type-safe Rust library for financial data. + +## Install + +`pip install finance-query-py` + +## Quickstart + +```python +import asyncio +from finance_query import Ticker + +async def main(): + ticker = await Ticker.new("AAPL") + quote = await ticker.quote() + print(quote.current_price) + +asyncio.run(main()) +``` + +See the [full documentation](https://finance-query.readthedocs.io/python/). + +## Development + +```bash +# Build the extension into the local venv +make develop + +# Run the test suite (network tests skipped by default) +make test + +# Regenerate type stubs (semi-automatic; async sigs are hand-polished) +make stubs +``` + +Local network tests need access to Yahoo Finance. Use `pytest -m network` to run them; they're deselected by default. + +## Releasing + +1. Bump the version in `Cargo.toml` and `pyproject.toml` to match the parent + `finance-query` crate version. They should always agree. +2. Tag and push: + ```bash + VERSION=$(cargo pkgid -p finance-query-python | cut -d# -f2) + git tag "finance-query-py-v${VERSION}" + git push origin --tags + ``` +3. CI (`.github/workflows/python-wheels.yml`) builds 5 wheels (linux x86_64, + linux aarch64, macos x86_64, macos aarch64, windows x86_64) plus an sdist + and publishes to PyPI via trusted publishing. + +### Trusted publishing setup (one-time) + +Before the first release tag will succeed, configure PyPI trusted publishing: + +1. Sign in to PyPI as the project owner. +2. Visit https://pypi.org/manage/account/publishing/ → "Add a new pending publisher". +3. Enter: + - PyPI Project Name: `finance-query-py` + - Owner: `Verdenroz` + - Repository name: `finance-query` + - Workflow name: `python-wheels.yml` + - Environment name: `pypi` +4. Save. The workflow's `publish` job will now upload to PyPI without needing an API token. diff --git a/finance-query-python/docs/examples/quickstart.ipynb b/finance-query-python/docs/examples/quickstart.ipynb new file mode 100644 index 00000000..a5fee619 --- /dev/null +++ b/finance-query-python/docs/examples/quickstart.ipynb @@ -0,0 +1,132 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# finance-query-py quickstart\n", + "\n", + "Native Python bindings for the finance-query Rust library. Async-first, type-safe, polars DataFrames.\n", + "\n", + "## Install\n", + "\n", + "```\n", + "pip install finance-query-py\n", + "```\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Quote a single ticker" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from finance_query import Ticker\n", + "\n", + "ticker = await Ticker.new(\"AAPL\")\n", + "quote = await ticker.quote()\n", + "print(f\"{quote.symbol}: {quote.short_name}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Chart → polars DataFrame" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from finance_query import Interval, TimeRange\n", + "\n", + "chart = await ticker.chart(Interval.OneDay, TimeRange.OneMonth)\n", + "df = chart.to_dataframe()\n", + "df.head()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Batch multiple tickers" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from finance_query import Tickers\n", + "\n", + "tickers = await Tickers.new([\"AAPL\", \"MSFT\", \"NVDA\"])\n", + "result = await tickers.quotes()\n", + "for sym, q in result.data.items():\n", + " print(f\"{sym}: {q.short_name}\")\n", + "if result.errors:\n", + " print(\"errors:\", result.errors)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Market search" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from finance_query import finance\n", + "\n", + "results = await finance.search(\"Apple\")\n", + "for r in results[:5]:\n", + " print(r.symbol)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Fear and Greed Index" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fng = await finance.fear_and_greed()\n", + "print(f\"{fng.value}/100 — {fng.classification}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/finance-query-python/finance_query/__init__.py b/finance-query-python/finance_query/__init__.py new file mode 100644 index 00000000..60aaf6ea --- /dev/null +++ b/finance-query-python/finance_query/__init__.py @@ -0,0 +1,59 @@ +"""finance-query-py — Python bindings for the finance-query Rust library.""" + +from ._finance_query import ( + __version__, + FinanceQueryError, + NetworkError, + RateLimitError, + SymbolNotFound, + ParseError, + ConfigError, + Interval, + TimeRange, + Frequency, + StatementType, + Region, + ValueFormat, + Sector, + Screener, + ExchangeCode, + Industry, + FearGreedLabel, + Ticker, + TickerBuilder, + Tickers, + BatchResult, + finance, + enable_logging, + edgar_init, + edgar_init_with_config, +) + +__all__ = [ + "__version__", + "FinanceQueryError", + "NetworkError", + "RateLimitError", + "SymbolNotFound", + "ParseError", + "ConfigError", + "Interval", + "TimeRange", + "Frequency", + "StatementType", + "Region", + "ValueFormat", + "Sector", + "Screener", + "ExchangeCode", + "Industry", + "FearGreedLabel", + "Ticker", + "TickerBuilder", + "Tickers", + "BatchResult", + "finance", + "enable_logging", + "edgar_init", + "edgar_init_with_config", +] diff --git a/finance-query-python/finance_query/_finance_query.pyi b/finance-query-python/finance_query/_finance_query.pyi new file mode 100644 index 00000000..664958ae --- /dev/null +++ b/finance-query-python/finance_query/_finance_query.pyi @@ -0,0 +1,295 @@ +"""Type stubs for the finance-query Rust extension module. + +These stubs cover the user-facing API. Model class attributes are listed for +common fields — full coverage of every PyO3 #[getter] is not necessary for +mypy strictness on call sites. +""" + +from collections.abc import Awaitable +from typing import Any, Optional + +# -------------------- Module metadata -------------------- + +__version__: str + +def enable_logging(level: str = "INFO") -> None: ... +def edgar_init(email: str) -> None: ... +def edgar_init_with_config(email: str, app_name: str, timeout_seconds: int = 60) -> None: ... + +# -------------------- Exceptions -------------------- + +class FinanceQueryError(Exception): ... +class NetworkError(FinanceQueryError): ... +class RateLimitError(FinanceQueryError): ... +class SymbolNotFound(FinanceQueryError): ... +class ParseError(FinanceQueryError): ... +class ConfigError(FinanceQueryError): ... + +# -------------------- Enums -------------------- + +class Interval: + OneMinute: "Interval" + FiveMinutes: "Interval" + FifteenMinutes: "Interval" + ThirtyMinutes: "Interval" + OneHour: "Interval" + OneDay: "Interval" + OneWeek: "Interval" + OneMonth: "Interval" + ThreeMonths: "Interval" + +class TimeRange: + OneDay: "TimeRange" + FiveDays: "TimeRange" + OneMonth: "TimeRange" + ThreeMonths: "TimeRange" + SixMonths: "TimeRange" + OneYear: "TimeRange" + TwoYears: "TimeRange" + FiveYears: "TimeRange" + TenYears: "TimeRange" + YearToDate: "TimeRange" + Max: "TimeRange" + +class Frequency: + Annual: "Frequency" + Quarterly: "Frequency" + +class StatementType: + Income: "StatementType" + Balance: "StatementType" + CashFlow: "StatementType" + +class ValueFormat: + Raw: "ValueFormat" + Pretty: "ValueFormat" + Both: "ValueFormat" + +class Region: + # ~28 country-level variants. Common ones documented for autocomplete; + # additional variants exist at runtime. + UnitedStates: "Region" + +class Sector: + Technology: "Sector" + FinancialServices: "Sector" + # ~11 total + +class Screener: + DayGainers: "Screener" + DayLosers: "Screener" + # ~15 total + +class ExchangeCode: + # ~20 variants — accessible at runtime. + ... + +class Industry: + # ~147 variants — accessible at runtime. + ... + +class FearGreedLabel: + ExtremeFear: "FearGreedLabel" + Fear: "FearGreedLabel" + Neutral: "FearGreedLabel" + Greed: "FearGreedLabel" + ExtremeGreed: "FearGreedLabel" + +# -------------------- Models (forward-declared; key fields only) -------------------- + +class Quote: + symbol: str + short_name: Optional[str] + long_name: Optional[str] + # Many more fields available at runtime via #[getter]; not all listed. + def to_dict(self) -> dict[str, Any]: ... + +class Candle: + open: float + high: float + low: float + close: float + volume: int + def to_dataframe(self) -> Any: ... # polars.DataFrame + def to_dict(self) -> dict[str, Any]: ... + +class ChartMeta: + def to_dict(self) -> dict[str, Any]: ... + +class Chart: + candles: list[Candle] + meta: ChartMeta + def to_dataframe(self) -> Any: ... # polars.DataFrame + def to_dict(self) -> dict[str, Any]: ... + +class Dividend: + def to_dataframe(self) -> Any: ... + def to_dict(self) -> dict[str, Any]: ... + +class Split: + def to_dataframe(self) -> Any: ... + def to_dict(self) -> dict[str, Any]: ... + +class CapitalGain: + def to_dataframe(self) -> Any: ... + def to_dict(self) -> dict[str, Any]: ... + +class FinancialStatement: + def to_dict(self) -> dict[str, Any]: ... + +class News: + title: str + link: str + publisher: str + def to_dataframe(self) -> Any: ... + def to_dict(self) -> dict[str, Any]: ... + +class Recommendation: + def to_dict(self) -> dict[str, Any]: ... + +class EdgarSubmissions: + def to_dict(self) -> dict[str, Any]: ... + +class SearchQuote: + symbol: str + def to_dataframe(self) -> Any: ... + def to_dict(self) -> dict[str, Any]: ... + +class ScreenerQuote: + symbol: str + def to_dict(self) -> dict[str, Any]: ... + +class ScreenerResults: + def to_dict(self) -> dict[str, Any]: ... + +class TrendingQuote: + symbol: str + def to_dict(self) -> dict[str, Any]: ... + +class FearAndGreed: + value: int + classification: FearGreedLabel + timestamp: int + def to_dict(self) -> dict[str, Any]: ... + +class LookupResults: + def to_dict(self) -> dict[str, Any]: ... + +class MarketSummaryQuote: + def to_dict(self) -> dict[str, Any]: ... + +class MarketHours: + def to_dict(self) -> dict[str, Any]: ... + +class SectorData: + def to_dict(self) -> dict[str, Any]: ... + +class Currency: + def to_dict(self) -> dict[str, Any]: ... + +class IndustryData: + def to_dict(self) -> dict[str, Any]: ... + +class Exchange: + def to_dict(self) -> dict[str, Any]: ... + +class FormattedValueF64: + raw: Optional[float] + fmt: Optional[str] + long_fmt: Optional[str] + +class FormattedValueI64: + raw: Optional[int] + fmt: Optional[str] + long_fmt: Optional[str] + +class FormattedValueU64: + raw: Optional[int] + fmt: Optional[str] + long_fmt: Optional[str] + +class FormattedValueString: + raw: Optional[str] + fmt: Optional[str] + long_fmt: Optional[str] + +# -------------------- BatchResult -------------------- + +class BatchResult: + data: dict[str, Any] + errors: dict[str, str] + +# -------------------- TickerBuilder -------------------- + +class TickerBuilder: + def lang(self, lang: str) -> "TickerBuilder": ... + def region_code(self, region: str) -> "TickerBuilder": ... + def region(self, region: Region) -> "TickerBuilder": ... + def timeout(self, seconds: int) -> "TickerBuilder": ... + def proxy(self, proxy: str) -> "TickerBuilder": ... + def cache(self, ttl_seconds: int) -> "TickerBuilder": ... + def logo(self) -> "TickerBuilder": ... + def build(self) -> Awaitable["Ticker"]: ... + +# -------------------- Ticker -------------------- + +class Ticker: + symbol: str + + @staticmethod + def new(symbol: str) -> Awaitable["Ticker"]: ... + @staticmethod + def builder(symbol: str) -> TickerBuilder: ... + def quote(self) -> Awaitable[Quote]: ... + def chart(self, interval: Interval, range: TimeRange) -> Awaitable[Chart]: ... + def chart_range( + self, interval: Interval, start: int, end: int + ) -> Awaitable[Chart]: ... + def dividends(self, range: TimeRange) -> Awaitable[list[Dividend]]: ... + def splits(self, range: TimeRange) -> Awaitable[list[Split]]: ... + def capital_gains(self, range: TimeRange) -> Awaitable[list[CapitalGain]]: ... + def financials( + self, statement: StatementType, frequency: Frequency + ) -> Awaitable[FinancialStatement]: ... + def news(self) -> Awaitable[list[News]]: ... + def recommendations(self, limit: int) -> Awaitable[Recommendation]: ... + def edgar_submissions(self) -> Awaitable[EdgarSubmissions]: ... + def clear_cache(self) -> Awaitable[None]: ... + def clear_quote_cache(self) -> Awaitable[None]: ... + def clear_chart_cache(self) -> Awaitable[None]: ... + +# -------------------- Tickers -------------------- + +class Tickers: + @staticmethod + def new(symbols: list[str]) -> Awaitable["Tickers"]: ... + def symbols(self) -> list[str]: ... + def __len__(self) -> int: ... + def quotes(self) -> Awaitable[BatchResult]: ... + def charts( + self, interval: Interval, range: TimeRange + ) -> Awaitable[BatchResult]: ... + +# -------------------- finance submodule -------------------- + +class _FinanceSubmodule: + def search(self, query: str) -> Awaitable[list[SearchQuote]]: ... + def screener( + self, screener: Screener, count: Optional[int] = ... + ) -> Awaitable[ScreenerResults]: ... + def trending( + self, region: Optional[Region] = ... + ) -> Awaitable[list[TrendingQuote]]: ... + def fear_and_greed(self) -> Awaitable[FearAndGreed]: ... + def lookup(self, query: str) -> Awaitable[LookupResults]: ... + def market_summary( + self, region: Optional[Region] = ... + ) -> Awaitable[list[MarketSummaryQuote]]: ... + def hours(self, region: Optional[str] = ...) -> Awaitable[MarketHours]: ... + def sector(self, sector_type: Sector) -> Awaitable[SectorData]: ... + def currencies(self) -> Awaitable[list[Currency]]: ... + def news(self) -> Awaitable[list[News]]: ... + def industry(self, industry_key: str) -> Awaitable[IndustryData]: ... + def exchanges(self) -> Awaitable[list[Exchange]]: ... + +finance: _FinanceSubmodule diff --git a/finance-query-python/finance_query/py.typed b/finance-query-python/finance_query/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/finance-query-python/pyproject.toml b/finance-query-python/pyproject.toml new file mode 100644 index 00000000..0832b3c8 --- /dev/null +++ b/finance-query-python/pyproject.toml @@ -0,0 +1,52 @@ +[build-system] +requires = ["maturin>=1.7"] +build-backend = "maturin" + +[project] +name = "finance-query-py" +version = "2.5.1" +description = "Python bindings for finance-query — fast, type-safe financial data" +requires-python = ">=3.9" +license = { text = "MIT" } +authors = [{ name = "Harvey Tseng", email = "harveytseng2@gmail.com" }] +readme = "README.md" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Rust", + "Topic :: Office/Business :: Financial", + "License :: OSI Approved :: MIT License", +] +# polars is required at runtime by `Chart.to_dataframe()` and friends — +# `pyo3-polars` constructs a `polars.DataFrame` Python object, which fails at +# call time if polars isn't installed. +dependencies = ["polars>=0.20"] + +[project.optional-dependencies] +pandas = ["pandas>=2.0", "pyarrow>=14"] +polars = ["polars>=0.20"] +all = ["pandas>=2.0", "pyarrow>=14", "polars>=0.20"] +dev = ["pytest>=8", "pytest-asyncio>=0.23", "nbmake>=1.5", "mypy>=1.8"] + +[tool.maturin] +features = ["pyo3/extension-module"] +python-source = "." +module-name = "finance_query._finance_query" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +markers = [ + "network: tests that require network access to Yahoo Finance", +] + +[tool.mypy] +strict = true +files = ["finance_query"] +ignore_missing_imports = true +warn_unused_ignores = false +disallow_subclassing_any = false diff --git a/finance-query-python/src/edgar.rs b/finance-query-python/src/edgar.rs new file mode 100644 index 00000000..79cfb954 --- /dev/null +++ b/finance-query-python/src/edgar.rs @@ -0,0 +1,28 @@ +//! SEC EDGAR initialization helpers. +//! +//! EDGAR requires a User-Agent containing a contact email per SEC's +//! [Fair Access](https://www.sec.gov/os/accessing-edgar-data) policy. +//! Call `edgar_init(email)` once at process start before any +//! `Ticker.edgar_submissions()` or related call. + +use crate::error::to_py_err; +use pyo3::prelude::*; +use std::time::Duration; + +#[pyfunction] +pub fn edgar_init(email: String) -> PyResult<()> { + finance_query::edgar::init(email).map_err(to_py_err) +} + +#[pyfunction] +#[pyo3(signature = (email, app_name, timeout_seconds = 60))] +pub fn edgar_init_with_config(email: String, app_name: String, timeout_seconds: u64) -> PyResult<()> { + finance_query::edgar::init_with_config(email, app_name, Duration::from_secs(timeout_seconds)) + .map_err(to_py_err) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(edgar_init, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(edgar_init_with_config, m)?)?; + Ok(()) +} diff --git a/finance-query-python/src/enums.rs b/finance-query-python/src/enums.rs new file mode 100644 index 00000000..cfc1bbb8 --- /dev/null +++ b/finance-query-python/src/enums.rs @@ -0,0 +1,28 @@ +//! Re-exports of the Python enum wrappers defined in finance-query. +//! +//! The enum mirror types (`PyInterval`, `PyTimeRange`, etc.) live in the core +//! `finance_query` crate (gated on `python` feature) so the `PyModel` derive +//! macro can resolve them from inside model files via `use crate::{...}`. +//! This module simply re-exports them and registers the classes with the +//! `_finance_query` Python module. + +use ::pyo3::prelude::*; + +pub use finance_query::{ + PyExchangeCode, PyFrequency, PyIndustry, PyInterval, PyRegion, PyScreener, PySector, + PyStatementType, PyTimeRange, PyValueFormat, +}; + +pub fn register(m: &::pyo3::Bound<'_, ::pyo3::types::PyModule>) -> ::pyo3::PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/finance-query-python/src/error.rs b/finance-query-python/src/error.rs new file mode 100644 index 00000000..44a0106c --- /dev/null +++ b/finance-query-python/src/error.rs @@ -0,0 +1,62 @@ +//! Maps `finance_query::FinanceError` variants to typed Python exceptions. +//! +//! This is the only module that bridges Rust errors to Python errors. +//! Every other binding uses `?` to propagate. + +use finance_query::FinanceError; +use pyo3::create_exception; +use pyo3::exceptions::PyException; +use pyo3::prelude::*; + +create_exception!(_finance_query, FinanceQueryError, PyException); +create_exception!(_finance_query, NetworkError, FinanceQueryError); +create_exception!(_finance_query, RateLimitError, FinanceQueryError); +create_exception!(_finance_query, SymbolNotFound, FinanceQueryError); +create_exception!(_finance_query, ParseError, FinanceQueryError); +create_exception!(_finance_query, ConfigError, FinanceQueryError); + +/// Convert a `FinanceError` into the appropriate typed Python exception. +#[allow(dead_code)] // Consumed by binding modules added in later tasks. +pub fn to_py_err(err: FinanceError) -> PyErr { + let msg = err.to_string(); + match err { + FinanceError::AuthenticationFailed { .. } + | FinanceError::InvalidParameter { .. } => ConfigError::new_err(msg), + + FinanceError::SymbolNotFound { .. } => SymbolNotFound::new_err(msg), + + FinanceError::RateLimited { .. } => RateLimitError::new_err(msg), + + FinanceError::HttpError(_) + | FinanceError::Timeout { .. } + | FinanceError::ServerError { .. } + | FinanceError::ExternalApiError { .. } + | FinanceError::MacroDataError { .. } => NetworkError::new_err(msg), + + FinanceError::JsonParseError(_) + | FinanceError::ResponseStructureError { .. } + | FinanceError::UnexpectedResponse(_) + | FinanceError::FeedParseError { .. } => ParseError::new_err(msg), + + // Catch-alls map to the base type. + // + // `IndicatorError` is gated on the upstream `finance-query` crate's + // `indicators` feature, which our Cargo.toml unconditionally enables, + // so the variant is always present here. + FinanceError::InternalError(_) + | FinanceError::ApiError(_) + | FinanceError::RuntimeError(_) + | FinanceError::IndicatorError(_) => FinanceQueryError::new_err(msg), + } +} + +/// Register all exception types on the module. +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("FinanceQueryError", m.py().get_type::())?; + m.add("NetworkError", m.py().get_type::())?; + m.add("RateLimitError", m.py().get_type::())?; + m.add("SymbolNotFound", m.py().get_type::())?; + m.add("ParseError", m.py().get_type::())?; + m.add("ConfigError", m.py().get_type::())?; + Ok(()) +} diff --git a/finance-query-python/src/finance.rs b/finance-query-python/src/finance.rs new file mode 100644 index 00000000..7d103eb4 --- /dev/null +++ b/finance-query-python/src/finance.rs @@ -0,0 +1,211 @@ +//! Top-level finance functions exposed on the `finance_query.finance` submodule. +//! +//! Mirrors the free functions in [`finance_query::finance`]: `search`, +//! `screener`, `trending`, `fear_and_greed`, `lookup`, `market_summary`, +//! `hours`, `sector`, `currencies`, `news`, `industry`, and `exchanges`. + +use crate::error::to_py_err; +use crate::models::{ + PyFearAndGreed, PyNews, PyScreenerQuote, PyScreenerResults, PySearchQuote, PyTrendingQuote, +}; +use finance_query::{ + PyCurrency, PyExchange, PyIndustryData, PyLookupResults, PyMarketHours, PyMarketSummaryQuote, + PyRegion, PyScreener, PySector, PySectorData, +}; +use pyo3::prelude::*; +use pyo3::wrap_pyfunction; + +/// Search Yahoo Finance for symbols, news, and research matching `query`. +/// +/// Currently uses `SearchOptions::default()`; richer options will be exposed +/// once `SearchOptions` itself has a Python wrapper. Returns just the quote +/// list — news/research-reports/total-time are deferred to a later task. +#[pyfunction] +fn search<'py>(py: Python<'py>, query: String) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let opts = finance_query::SearchOptions::default(); + let r = finance_query::finance::search(&query, &opts) + .await + .map_err(to_py_err)?; + let py_vec: Vec = r.quotes.0.into_iter().map(Into::into).collect(); + Ok(py_vec) + }) +} + +/// Run a Yahoo Finance predefined screener (e.g. `Screener.DayGainers`). +/// +/// `count` defaults to 25 to match Yahoo's typical screener page size. +#[pyfunction] +#[pyo3(signature = (screener, count=25))] +fn screener<'py>( + py: Python<'py>, + screener: PyScreener, + count: u32, +) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = finance_query::finance::screener(screener.into(), count) + .await + .map_err(to_py_err)?; + Ok(PyScreenerResults::from(r)) + }) +} + +/// Fetch the trending tickers for `region` (defaults to US when `None`). +#[pyfunction] +#[pyo3(signature = (region=None))] +fn trending<'py>(py: Python<'py>, region: Option) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = finance_query::finance::trending(region.map(Into::into)) + .await + .map_err(to_py_err)?; + let py_vec: Vec = r.into_iter().map(Into::into).collect(); + Ok(py_vec) + }) +} + +/// Fetch the current CNN Fear & Greed Index from Alternative.me. +#[pyfunction] +fn fear_and_greed<'py>(py: Python<'py>) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = finance_query::finance::fear_and_greed() + .await + .map_err(to_py_err)?; + Ok(PyFearAndGreed::from(r)) + }) +} + +/// Look up Yahoo Finance symbols by name/ticker prefix (`query`). +/// +/// Uses `LookupOptions::default()`; richer filtering will be exposed once +/// `LookupOptions` itself has a Python wrapper. +#[pyfunction] +fn lookup<'py>(py: Python<'py>, query: String) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let opts = finance_query::LookupOptions::default(); + let r = finance_query::finance::lookup(&query, &opts) + .await + .map_err(to_py_err)?; + Ok(PyLookupResults::from(r)) + }) +} + +/// Fetch Yahoo Finance's market summary quotes for `region` (defaults to US). +#[pyfunction] +#[pyo3(signature = (region=None))] +fn market_summary<'py>( + py: Python<'py>, + region: Option, +) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = finance_query::finance::market_summary(region.map(Into::into)) + .await + .map_err(to_py_err)?; + let py_vec: Vec = r.into_iter().map(Into::into).collect(); + Ok(py_vec) + }) +} + +/// Fetch market hours for `region` (string code like "us"/"uk"; None = all). +#[pyfunction] +#[pyo3(signature = (region=None))] +fn hours<'py>(py: Python<'py>, region: Option) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = finance_query::finance::hours(region.as_deref()) + .await + .map_err(to_py_err)?; + Ok(PyMarketHours::from(r)) + }) +} + +/// Fetch sector data for a given `Sector` enum value (e.g. `Sector.Technology`). +#[pyfunction] +fn sector<'py>(py: Python<'py>, sector_type: PySector) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = finance_query::finance::sector(sector_type.into()) + .await + .map_err(to_py_err)?; + Ok(PySectorData::from(r)) + }) +} + +/// Fetch Yahoo Finance's list of supported currencies. +#[pyfunction] +fn currencies<'py>(py: Python<'py>) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = finance_query::finance::currencies() + .await + .map_err(to_py_err)?; + let py_vec: Vec = r.into_iter().map(Into::into).collect(); + Ok(py_vec) + }) +} + +/// Fetch the latest scraped Yahoo Finance news headlines. +#[pyfunction] +fn news<'py>(py: Python<'py>) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = finance_query::finance::news().await.map_err(to_py_err)?; + let py_vec: Vec = r.into_iter().map(Into::into).collect(); + Ok(py_vec) + }) +} + +/// Fetch industry data for the given industry key (e.g. `"semiconductors"`). +#[pyfunction] +fn industry<'py>(py: Python<'py>, industry_key: String) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = finance_query::finance::industry(&industry_key) + .await + .map_err(to_py_err)?; + Ok(PyIndustryData::from(r)) + }) +} + +/// Fetch Yahoo Finance's list of supported exchanges. +#[pyfunction] +fn exchanges<'py>(py: Python<'py>) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = finance_query::finance::exchanges() + .await + .map_err(to_py_err)?; + let py_vec: Vec = r.into_iter().map(Into::into).collect(); + Ok(py_vec) + }) +} + +/// Register the `finance` submodule and its function members on `parent`. +pub fn register(parent: &Bound<'_, PyModule>) -> PyResult<()> { + let py = parent.py(); + let m = PyModule::new(py, "finance")?; + m.add_function(wrap_pyfunction!(search, &m)?)?; + m.add_function(wrap_pyfunction!(screener, &m)?)?; + m.add_function(wrap_pyfunction!(trending, &m)?)?; + m.add_function(wrap_pyfunction!(fear_and_greed, &m)?)?; + m.add_function(wrap_pyfunction!(lookup, &m)?)?; + m.add_function(wrap_pyfunction!(market_summary, &m)?)?; + m.add_function(wrap_pyfunction!(hours, &m)?)?; + m.add_function(wrap_pyfunction!(sector, &m)?)?; + m.add_function(wrap_pyfunction!(currencies, &m)?)?; + m.add_function(wrap_pyfunction!(news, &m)?)?; + m.add_function(wrap_pyfunction!(industry, &m)?)?; + m.add_function(wrap_pyfunction!(exchanges, &m)?)?; + + // Expose the response types on the submodule so users can introspect + // them via e.g. `finance_query.finance.ScreenerResults`. + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + parent.add_submodule(&m)?; + Ok(()) +} diff --git a/finance-query-python/src/lib.rs b/finance-query-python/src/lib.rs new file mode 100644 index 00000000..c85c597c --- /dev/null +++ b/finance-query-python/src/lib.rs @@ -0,0 +1,30 @@ +//! Python bindings for the finance-query Rust library. +//! +//! See the design spec at `docs/superpowers/specs/2026-05-12-python-bindings-design.md`. + +use pyo3::prelude::*; + +mod edgar; +mod enums; +mod error; +mod finance; +mod logging_bridge; +mod models; +mod runtime; +mod ticker; +mod tickers; + +#[pymodule] +fn _finance_query(m: &Bound<'_, PyModule>) -> PyResult<()> { + runtime::init_runtime()?; + error::register(m)?; + enums::register(m)?; + ticker::register(m)?; + tickers::register(m)?; + finance::register(m)?; + logging_bridge::register(m)?; + edgar::register(m)?; + m.add_class::()?; + m.add("__version__", env!("CARGO_PKG_VERSION"))?; + Ok(()) +} diff --git a/finance-query-python/src/logging_bridge.rs b/finance-query-python/src/logging_bridge.rs new file mode 100644 index 00000000..17a18650 --- /dev/null +++ b/finance-query-python/src/logging_bridge.rs @@ -0,0 +1,34 @@ +//! Bridge from Rust `tracing` events to user-visible logs. +//! +//! Phase 1 installs a stderr fmt subscriber. Phase 3 may replace this with a +//! proper bridge to Python's `logging` module. + +use pyo3::prelude::*; + +#[pyfunction] +#[pyo3(signature = (level = "INFO"))] +pub fn enable_logging(level: &str) -> PyResult<()> { + let level_filter = match level.to_uppercase().as_str() { + "TRACE" => tracing::Level::TRACE, + "DEBUG" => tracing::Level::DEBUG, + "INFO" => tracing::Level::INFO, + "WARN" => tracing::Level::WARN, + "ERROR" => tracing::Level::ERROR, + other => { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "invalid log level: {} (expected TRACE/DEBUG/INFO/WARN/ERROR)", + other + ))); + } + }; + // try_init() returns Err if a subscriber is already set — that's fine; ignore. + let _ = tracing_subscriber::fmt() + .with_max_level(level_filter) + .try_init(); + Ok(()) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(enable_logging, m)?)?; + Ok(()) +} diff --git a/finance-query-python/src/models.rs b/finance-query-python/src/models.rs new file mode 100644 index 00000000..34b23fe2 --- /dev/null +++ b/finance-query-python/src/models.rs @@ -0,0 +1,9 @@ +//! Re-exports of the derive-generated `Py*` wrapper types from `finance-query`. +//! +//! Types are added to this module as Ticker/Tickers/finance methods need them. + +pub use finance_query::{ + PyCapitalGain, PyChart, PyDividend, PyEdgarSubmissions, PyFearAndGreed, PyFearGreedLabel, + PyFinancialStatement, PyNews, PyQuote, PyRecommendation, PyScreenerQuote, PyScreenerResults, + PySearchQuote, PySplit, PyTrendingQuote, +}; diff --git a/finance-query-python/src/runtime.rs b/finance-query-python/src/runtime.rs new file mode 100644 index 00000000..8ddb66cb --- /dev/null +++ b/finance-query-python/src/runtime.rs @@ -0,0 +1,28 @@ +//! Shared tokio runtime singleton, initialised once on module init. + +use pyo3::prelude::*; + +pub fn init_runtime() -> PyResult<()> { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name("finance-query-py") + .build() + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("tokio init failed: {}", e)))?; + let rt: &'static tokio::runtime::Runtime = Box::leak(Box::new(rt)); + pyo3_async_runtimes::tokio::init_with_runtime(rt) + .map_err(|_| pyo3::exceptions::PyRuntimeError::new_err("runtime already initialised"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_initialises_once() { + Python::initialize(); + assert!(init_runtime().is_ok()); + // Second call should error (already initialised) + assert!(init_runtime().is_err()); + } +} diff --git a/finance-query-python/src/ticker.rs b/finance-query-python/src/ticker.rs new file mode 100644 index 00000000..0a3f51c1 --- /dev/null +++ b/finance-query-python/src/ticker.rs @@ -0,0 +1,259 @@ +//! Python wrapper for `finance_query::Ticker`. + +use crate::error::to_py_err; +use crate::models::{ + PyCapitalGain, PyChart, PyDividend, PyEdgarSubmissions, PyFinancialStatement, PyNews, PyQuote, + PyRecommendation, PySplit, +}; +use pyo3::prelude::*; +use std::sync::Arc; +use std::time::Duration; + +#[pyclass(frozen, name = "Ticker")] +pub struct PyTicker { + inner: Arc, +} + +#[pymethods] +impl PyTicker { + #[staticmethod] + fn new<'py>(py: Python<'py>, symbol: String) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let t = finance_query::Ticker::new(symbol).await.map_err(to_py_err)?; + Ok(PyTicker { inner: Arc::new(t) }) + }) + } + + #[staticmethod] + fn builder(symbol: String) -> PyTickerBuilder { + PyTickerBuilder { + inner: Some(finance_query::Ticker::builder(symbol)), + } + } + + #[getter] + fn symbol(&self) -> String { + self.inner.symbol().to_string() + } + + fn quote<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let q = inner.quote().await.map_err(to_py_err)?; + Ok(PyQuote::from(q)) + }) + } + + fn chart<'py>( + &self, + py: Python<'py>, + interval: finance_query::PyInterval, + range: finance_query::PyTimeRange, + ) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let c = inner + .chart(interval.into(), range.into()) + .await + .map_err(to_py_err)?; + Ok(PyChart::from(c)) + }) + } + + fn chart_range<'py>( + &self, + py: Python<'py>, + interval: finance_query::PyInterval, + start: i64, + end: i64, + ) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let c = inner + .chart_range(interval.into(), start, end) + .await + .map_err(to_py_err)?; + Ok(PyChart::from(c)) + }) + } + + fn dividends<'py>( + &self, + py: Python<'py>, + range: finance_query::PyTimeRange, + ) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = inner.dividends(range.into()).await.map_err(to_py_err)?; + let py_vec: Vec = r.into_iter().map(Into::into).collect(); + Ok(py_vec) + }) + } + + fn splits<'py>( + &self, + py: Python<'py>, + range: finance_query::PyTimeRange, + ) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = inner.splits(range.into()).await.map_err(to_py_err)?; + let py_vec: Vec = r.into_iter().map(Into::into).collect(); + Ok(py_vec) + }) + } + + fn capital_gains<'py>( + &self, + py: Python<'py>, + range: finance_query::PyTimeRange, + ) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = inner.capital_gains(range.into()).await.map_err(to_py_err)?; + let py_vec: Vec = r.into_iter().map(Into::into).collect(); + Ok(py_vec) + }) + } + + fn financials<'py>( + &self, + py: Python<'py>, + statement: finance_query::PyStatementType, + frequency: finance_query::PyFrequency, + ) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = inner + .financials(statement.into(), frequency.into()) + .await + .map_err(to_py_err)?; + Ok(PyFinancialStatement::from(r)) + }) + } + + fn news<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = inner.news().await.map_err(to_py_err)?; + let py_vec: Vec = r.into_iter().map(Into::into).collect(); + Ok(py_vec) + }) + } + + fn recommendations<'py>(&self, py: Python<'py>, limit: u32) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = inner.recommendations(limit).await.map_err(to_py_err)?; + Ok(PyRecommendation::from(r)) + }) + } + + fn edgar_submissions<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = inner.edgar_submissions().await.map_err(to_py_err)?; + Ok(PyEdgarSubmissions::from(r)) + }) + } + + fn clear_cache<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + inner.clear_cache().await; + Ok(()) + }) + } + + fn clear_quote_cache<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + inner.clear_quote_cache().await; + Ok(()) + }) + } + + fn clear_chart_cache<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + inner.clear_chart_cache().await; + Ok(()) + }) + } +} + +#[pyclass(name = "TickerBuilder")] +pub struct PyTickerBuilder { + inner: Option, +} + +#[pymethods] +impl PyTickerBuilder { + fn lang(mut slf: PyRefMut<'_, Self>, lang: String) -> PyRefMut<'_, Self> { + if let Some(b) = slf.inner.take() { + slf.inner = Some(b.lang(lang)); + } + slf + } + + fn region_code(mut slf: PyRefMut<'_, Self>, region: String) -> PyRefMut<'_, Self> { + if let Some(b) = slf.inner.take() { + slf.inner = Some(b.region_code(region)); + } + slf + } + + fn region( + mut slf: PyRefMut<'_, Self>, + region: finance_query::PyRegion, + ) -> PyRefMut<'_, Self> { + if let Some(b) = slf.inner.take() { + slf.inner = Some(b.region(region.into())); + } + slf + } + + fn timeout(mut slf: PyRefMut<'_, Self>, seconds: u64) -> PyRefMut<'_, Self> { + if let Some(b) = slf.inner.take() { + slf.inner = Some(b.timeout(Duration::from_secs(seconds))); + } + slf + } + + fn proxy(mut slf: PyRefMut<'_, Self>, proxy: String) -> PyRefMut<'_, Self> { + if let Some(b) = slf.inner.take() { + slf.inner = Some(b.proxy(proxy)); + } + slf + } + + fn cache(mut slf: PyRefMut<'_, Self>, ttl_seconds: u64) -> PyRefMut<'_, Self> { + if let Some(b) = slf.inner.take() { + slf.inner = Some(b.cache(Duration::from_secs(ttl_seconds))); + } + slf + } + + fn logo(mut slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> { + if let Some(b) = slf.inner.take() { + slf.inner = Some(b.logo()); + } + slf + } + + fn build<'py>(&mut self, py: Python<'py>) -> PyResult> { + let b = self.inner.take().ok_or_else(|| { + pyo3::exceptions::PyRuntimeError::new_err("builder already consumed") + })?; + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let t = b.build().await.map_err(to_py_err)?; + Ok(PyTicker { inner: Arc::new(t) }) + }) + } +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/finance-query-python/src/tickers.rs b/finance-query-python/src/tickers.rs new file mode 100644 index 00000000..726b8e96 --- /dev/null +++ b/finance-query-python/src/tickers.rs @@ -0,0 +1,103 @@ +//! Python wrapper for `finance_query::Tickers` (batch ticker operations). + +use crate::error::to_py_err; +use crate::models::{PyChart, PyQuote}; +use pyo3::prelude::*; +use pyo3::types::PyDict; +use std::sync::Arc; + +#[pyclass(frozen, name = "Tickers")] +pub struct PyTickers { + inner: Arc, +} + +#[pyclass(frozen, name = "BatchResult")] +pub struct PyBatchResult { + #[pyo3(get)] + data: PyObject, + #[pyo3(get)] + errors: PyObject, +} + +#[pymethods] +impl PyTickers { + #[staticmethod] + fn new<'py>(py: Python<'py>, symbols: Vec) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let t = finance_query::Tickers::new(symbols) + .await + .map_err(to_py_err)?; + Ok(PyTickers { + inner: Arc::new(t), + }) + }) + } + + fn symbols(&self) -> Vec { + self.inner + .symbols() + .into_iter() + .map(String::from) + .collect() + } + + fn __len__(&self) -> usize { + self.inner.len() + } + + fn quotes<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = inner.quotes().await.map_err(to_py_err)?; + Python::with_gil(|py| { + let data = PyDict::new(py); + for (sym, q) in r.quotes { + data.set_item(sym, PyQuote::from(q))?; + } + let errors = PyDict::new(py); + for (sym, e) in r.errors { + errors.set_item(sym, e)?; + } + Ok(PyBatchResult { + data: data.into_any().unbind(), + errors: errors.into_any().unbind(), + }) + }) + }) + } + + fn charts<'py>( + &self, + py: Python<'py>, + interval: finance_query::PyInterval, + range: finance_query::PyTimeRange, + ) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r = inner + .charts(interval.into(), range.into()) + .await + .map_err(to_py_err)?; + Python::with_gil(|py| { + let data = PyDict::new(py); + for (sym, c) in r.charts { + data.set_item(sym, PyChart::from(c))?; + } + let errors = PyDict::new(py); + for (sym, e) in r.errors { + errors.set_item(sym, e)?; + } + Ok(PyBatchResult { + data: data.into_any().unbind(), + errors: errors.into_any().unbind(), + }) + }) + }) + } +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/finance-query-python/tests/conftest.py b/finance-query-python/tests/conftest.py new file mode 100644 index 00000000..2dff486f --- /dev/null +++ b/finance-query-python/tests/conftest.py @@ -0,0 +1,3 @@ +"""pytest configuration for finance-query-py tests.""" + +pytest_plugins = ["pytest_asyncio"] diff --git a/finance-query-python/tests/parity/__init__.py b/finance-query-python/tests/parity/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/finance-query-python/tests/parity/conftest.py b/finance-query-python/tests/parity/conftest.py new file mode 100644 index 00000000..dd7f8864 --- /dev/null +++ b/finance-query-python/tests/parity/conftest.py @@ -0,0 +1,43 @@ +"""Parity test fixtures — JSON files produced by `cargo run -p record-parity-fixtures`.""" + +import json +from pathlib import Path + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +def fixture_path(name: str) -> Path: + """Path to a parity fixture (does NOT verify existence).""" + return FIXTURES_DIR / f"{name}.json" + + +def load_fixture(name: str) -> dict: + """Load a parity fixture; raises FileNotFoundError if missing.""" + return json.loads(fixture_path(name).read_text()) + + +def fixture_available(name: str) -> bool: + """Check whether a fixture file exists on disk.""" + return fixture_path(name).is_file() + + +# Time-variant fields excluded from strict equality comparisons. +TIME_VARIANT_FIELDS = { + "current_price", + "regular_market_change", + "regular_market_change_percent", + "regular_market_time", + "regular_market_volume", + "last_traded_at", + "pre_market_time", + "post_market_time", +} + + +def strip_time_variant(d): + """Recursively strip time-varying fields from a dict/list for stable comparison.""" + if isinstance(d, dict): + return {k: strip_time_variant(v) for k, v in d.items() if k not in TIME_VARIANT_FIELDS} + if isinstance(d, list): + return [strip_time_variant(x) for x in d] + return d diff --git a/finance-query-python/tests/parity/fixtures/.gitkeep b/finance-query-python/tests/parity/fixtures/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/finance-query-python/tests/parity/test_quote_parity.py b/finance-query-python/tests/parity/test_quote_parity.py new file mode 100644 index 00000000..bd91e45b --- /dev/null +++ b/finance-query-python/tests/parity/test_quote_parity.py @@ -0,0 +1,56 @@ +"""Cross-language parity: Python output must match recorded Rust JSON for the same symbol. + +The fixture files in `fixtures/` are produced by: + cargo run -p record-parity-fixtures -- AAPL MSFT NVDA TSLA SPY BTC-USD ETH-USD EURUSD=X GBPUSD=X GC=F + +Tests are skipped when fixtures are missing. +""" + +import pytest +from finance_query import Ticker + +from .conftest import fixture_available, load_fixture, strip_time_variant + + +@pytest.mark.parametrize( + "symbol,fixture_name", + [ + ("AAPL", "quote_aapl"), + ("MSFT", "quote_msft"), + ("NVDA", "quote_nvda"), + ("TSLA", "quote_tsla"), + ("SPY", "quote_spy"), + ("BTC-USD", "quote_btc_usd"), + ("ETH-USD", "quote_eth_usd"), + ("EURUSD=X", "quote_eurusd_x"), + ("GBPUSD=X", "quote_gbpusd_x"), + ("GC=F", "quote_gc_f"), + ], +) +@pytest.mark.asyncio +@pytest.mark.network +async def test_quote_parity(symbol: str, fixture_name: str): + """Compare live Python output against the recorded Rust JSON fixture.""" + if not fixture_available(fixture_name): + pytest.skip(f"fixture {fixture_name} not yet recorded — run record-parity in CI") + + expected = load_fixture(fixture_name) + ticker = await Ticker.new(symbol) + quote = await ticker.quote() + actual = quote.to_dict() + + expected_stripped = strip_time_variant(expected) + actual_stripped = strip_time_variant(actual) + + # Symbol must always match + assert actual.get("symbol") == expected.get("symbol"), f"{symbol}: symbol mismatch" + # Strip-equal comparison + diff_keys = set(expected_stripped.keys()) ^ set(actual_stripped.keys()) + assert not diff_keys, f"{symbol}: schema drift, differing keys: {diff_keys}" + + +def test_parity_infrastructure_present(): + """Sanity: the fixtures directory exists and the recorder is reachable.""" + from pathlib import Path + fixtures = Path(__file__).parent / "fixtures" + assert fixtures.is_dir(), "parity fixtures dir missing" diff --git a/finance-query-python/tests/smoke/__init__.py b/finance-query-python/tests/smoke/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/finance-query-python/tests/smoke/test_smoke.py b/finance-query-python/tests/smoke/test_smoke.py new file mode 100644 index 00000000..b806fbcf --- /dev/null +++ b/finance-query-python/tests/smoke/test_smoke.py @@ -0,0 +1,49 @@ +"""Smoke tests run against an installed wheel (not maturin develop build). + +These verify the wheel imports cleanly and basic types are usable. +NO network calls — only structural checks on the loaded module. +""" + +import finance_query + + +def test_module_version(): + assert isinstance(finance_query.__version__, str) + assert finance_query.__version__ + + +def test_enum_imports(): + assert finance_query.Interval.OneDay is not None + assert finance_query.TimeRange.OneMonth is not None + assert finance_query.Frequency.Annual is not None + assert finance_query.StatementType.Income is not None + + +def test_error_imports(): + assert issubclass(finance_query.NetworkError, finance_query.FinanceQueryError) + assert issubclass(finance_query.RateLimitError, finance_query.FinanceQueryError) + assert issubclass(finance_query.SymbolNotFound, finance_query.FinanceQueryError) + assert issubclass(finance_query.ParseError, finance_query.FinanceQueryError) + assert issubclass(finance_query.ConfigError, finance_query.FinanceQueryError) + + +def test_ticker_class_exists(): + assert finance_query.Ticker is not None + assert hasattr(finance_query.Ticker, "new") + assert hasattr(finance_query.Ticker, "builder") + + +def test_tickers_class_exists(): + assert finance_query.Tickers is not None + assert hasattr(finance_query.Tickers, "new") + + +def test_finance_submodule_exists(): + assert finance_query.finance is not None + assert hasattr(finance_query.finance, "search") + assert hasattr(finance_query.finance, "screener") + assert hasattr(finance_query.finance, "fear_and_greed") + + +def test_enable_logging_callable(): + finance_query.enable_logging(level="WARN") diff --git a/finance-query-python/tests/test_dataframe.py b/finance-query-python/tests/test_dataframe.py new file mode 100644 index 00000000..40530018 --- /dev/null +++ b/finance-query-python/tests/test_dataframe.py @@ -0,0 +1,29 @@ +"""Tests for Chart.to_dataframe() returning a polars DataFrame. + +These require network access — they're skipped when Yahoo Finance is unreachable. +""" + +import pytest + +try: + import polars as pl + HAS_POLARS = True +except ImportError: + HAS_POLARS = False + +from finance_query import Ticker, Interval, TimeRange + + +@pytest.mark.asyncio +@pytest.mark.network +@pytest.mark.skipif(not HAS_POLARS, reason="polars not installed") +async def test_chart_to_dataframe_returns_polars_dataframe(): + ticker = await Ticker.new("AAPL") + chart = await ticker.chart(Interval.OneDay, TimeRange.OneMonth) + df = chart.to_dataframe() + assert isinstance(df, pl.DataFrame) + expected_cols = {"open", "high", "low", "close", "volume"} + actual_cols = set(df.columns) + assert expected_cols.issubset(actual_cols), \ + f"missing: {expected_cols - actual_cols}; got: {actual_cols}" + assert len(df) == len(chart.candles) diff --git a/finance-query-python/tests/test_enums.py b/finance-query-python/tests/test_enums.py new file mode 100644 index 00000000..e05bb06f --- /dev/null +++ b/finance-query-python/tests/test_enums.py @@ -0,0 +1,82 @@ +"""Tests for Python enum exposure of Rust constants.""" + +from finance_query import Interval, TimeRange, Frequency + + +def test_interval_variants_exist(): + assert Interval.OneMinute is not None + assert Interval.OneDay is not None + assert Interval.OneWeek is not None + assert Interval.OneMonth is not None + assert Interval.ThreeMonths is not None + + +def test_time_range_variants_exist(): + assert TimeRange.OneDay is not None + assert TimeRange.OneMonth is not None + assert TimeRange.OneYear is not None + assert TimeRange.YearToDate is not None + assert TimeRange.Max is not None + + +def test_frequency_variants_exist(): + assert Frequency.Annual is not None + assert Frequency.Quarterly is not None + + +def test_enum_equality(): + assert Interval.OneDay == Interval.OneDay + assert Interval.OneDay != Interval.OneMinute + + +def test_enum_repr(): + assert "OneDay" in repr(Interval.OneDay) or "Interval" in repr(Interval.OneDay) + + +from finance_query import ( + StatementType, + Region, + ValueFormat, + Sector, + Screener, + ExchangeCode, + Industry, +) + + +def test_statement_type_variants(): + assert StatementType.Income is not None + assert StatementType.Balance is not None + assert StatementType.CashFlow is not None + + +def test_value_format_variants(): + assert ValueFormat.Raw is not None + assert ValueFormat.Pretty is not None + assert ValueFormat.Both is not None + + +def test_region_country_level(): + assert Region.UnitedStates is not None + assert Region.France is not None + assert Region.Japan if hasattr(Region, "Japan") else Region.UnitedKingdom is not None + + +def test_sector_at_least_one_variant(): + assert Sector.Technology is not None + assert len([attr for attr in dir(Sector) if not attr.startswith("_")]) > 5 + + +def test_screener_at_least_one_variant(): + assert Screener.MostActives is not None + assert len([attr for attr in dir(Screener) if not attr.startswith("_")]) > 3 + + +def test_exchange_code_at_least_one_variant(): + assert ExchangeCode.Nms is not None + assert len([attr for attr in dir(ExchangeCode) if not attr.startswith("_")]) > 3 + + +def test_industry_at_least_one_variant(): + assert Industry.Semiconductors is not None + assert len([attr for attr in dir(Industry) if not attr.startswith("_")]) > 3 diff --git a/finance-query-python/tests/test_errors.py b/finance-query-python/tests/test_errors.py new file mode 100644 index 00000000..d93017a1 --- /dev/null +++ b/finance-query-python/tests/test_errors.py @@ -0,0 +1,35 @@ +"""Tests for the exception hierarchy and error mapping.""" + +import pytest +from finance_query import ( + FinanceQueryError, + NetworkError, + RateLimitError, + SymbolNotFound, + ParseError, + ConfigError, +) + + +def test_exceptions_importable_and_subclass_base(): + assert issubclass(NetworkError, FinanceQueryError) + assert issubclass(RateLimitError, FinanceQueryError) + assert issubclass(SymbolNotFound, FinanceQueryError) + assert issubclass(ParseError, FinanceQueryError) + assert issubclass(ConfigError, FinanceQueryError) + + +def test_symbol_not_found_message_carries_symbol(): + # When raised from Python (no Rust mapping), the message is whatever was passed. + try: + raise SymbolNotFound("UNKNOWN-SYMBOL") + except SymbolNotFound as e: + assert "UNKNOWN-SYMBOL" in str(e) + + +def test_rate_limit_constructable(): + # Verify Python-side raising works; Rust-side population of retry_after + # is exercised via integration tests in a later task. + err = RateLimitError("rate limited") + assert isinstance(err, FinanceQueryError) + assert "rate limited" in str(err) diff --git a/finance-query-python/tests/test_finance.py b/finance-query-python/tests/test_finance.py new file mode 100644 index 00000000..f3d048a3 --- /dev/null +++ b/finance-query-python/tests/test_finance.py @@ -0,0 +1,92 @@ +"""Tests for the `finance_query.finance` submodule. + +Covers presence/wiring of the top-level free functions and the +`FearGreedLabel` enum. Network-backed assertions are marked so they can be +skipped in CI runs that don't allow outbound HTTP. +""" + +import pytest + +from finance_query import FearGreedLabel, Region, Screener, finance + + +def test_finance_submodule_has_functions(): + expected = {"search", "screener", "trending", "fear_and_greed"} + actual = {name for name in dir(finance) if not name.startswith("_")} + missing = expected - actual + assert not missing, f"finance submodule missing functions: {missing}" + + +def test_finance_submodule_exposes_response_types(): + # The wrapper response/quote types should be reachable via the submodule + # so users can isinstance-check or read getters off them. + expected = {"ScreenerResults", "ScreenerQuote", "SearchQuote", "TrendingQuote", "FearAndGreed"} + actual = {name for name in dir(finance) if not name.startswith("_")} + missing = expected - actual + assert not missing, f"finance submodule missing types: {missing}" + + +def test_fear_greed_label_variants(): + # Sanity-check the 5 known variants are reachable as Python attributes. + assert FearGreedLabel.ExtremeFear is not None + assert FearGreedLabel.Fear is not None + assert FearGreedLabel.Neutral is not None + assert FearGreedLabel.Greed is not None + assert FearGreedLabel.ExtremeGreed is not None + # Equality should follow Python's enum semantics (eq_int on pyclass). + assert FearGreedLabel.Fear == FearGreedLabel.Fear + assert FearGreedLabel.Fear != FearGreedLabel.Greed + + +@pytest.mark.asyncio +@pytest.mark.network +async def test_search_returns_results(): + results = await finance.search("Apple") + assert isinstance(results, list) + + +@pytest.mark.asyncio +@pytest.mark.network +async def test_screener_returns_results(): + res = await finance.screener(Screener.DayGainers, 5) + # PyScreenerResults wraps Vec; expose .quotes getter. + assert hasattr(res, "quotes") + assert isinstance(res.quotes, list) + + +@pytest.mark.asyncio +@pytest.mark.network +async def test_trending_returns_results(): + results = await finance.trending(Region.UnitedStates) + assert isinstance(results, list) + + +@pytest.mark.asyncio +@pytest.mark.network +async def test_trending_default_region(): + results = await finance.trending() + assert isinstance(results, list) + + +@pytest.mark.asyncio +@pytest.mark.network +async def test_fear_and_greed_returns_value(): + fng = await finance.fear_and_greed() + assert 0 <= fng.value <= 100 + assert fng.classification in { + FearGreedLabel.ExtremeFear, + FearGreedLabel.Fear, + FearGreedLabel.Neutral, + FearGreedLabel.Greed, + FearGreedLabel.ExtremeGreed, + } + + +def test_finance_submodule_has_all_functions(): + expected = { + "search", "screener", "trending", "fear_and_greed", + "lookup", "market_summary", "hours", "sector", + "currencies", "news", "industry", "exchanges", + } + actual = {name for name in dir(finance) if not name.startswith("_")} + assert expected.issubset(actual), f"missing: {expected - actual}" diff --git a/finance-query-python/tests/test_jupyter.py b/finance-query-python/tests/test_jupyter.py new file mode 100644 index 00000000..2223e147 --- /dev/null +++ b/finance-query-python/tests/test_jupyter.py @@ -0,0 +1,27 @@ +"""Smoke test for the Jupyter quickstart notebook.""" + +import json +from pathlib import Path + + +def test_quickstart_notebook_is_valid_json(): + nb_path = Path(__file__).parent.parent / "docs" / "examples" / "quickstart.ipynb" + assert nb_path.exists(), f"notebook missing: {nb_path}" + with nb_path.open() as f: + data = json.load(f) + assert data["nbformat"] == 4 + assert len(data["cells"]) > 0 + # Ensure at least one code cell exists + code_cells = [c for c in data["cells"] if c["cell_type"] == "code"] + assert len(code_cells) > 0 + + +def test_quickstart_imports_finance_query(): + """The notebook should reference finance_query in its code cells.""" + nb_path = Path(__file__).parent.parent / "docs" / "examples" / "quickstart.ipynb" + with nb_path.open() as f: + data = json.load(f) + code_text = "".join( + "".join(c["source"]) for c in data["cells"] if c["cell_type"] == "code" + ) + assert "finance_query" in code_text or "from finance_query" in code_text diff --git a/finance-query-python/tests/test_ticker.py b/finance-query-python/tests/test_ticker.py new file mode 100644 index 00000000..de330f05 --- /dev/null +++ b/finance-query-python/tests/test_ticker.py @@ -0,0 +1,119 @@ +"""Tests for PyTicker — Ticker.new() and ticker.quote().""" + +import pytest +from finance_query import Ticker, Interval, TimeRange, StatementType, Frequency + + +@pytest.mark.asyncio +@pytest.mark.network +async def test_ticker_new_returns_ticker_with_symbol(): + ticker = await Ticker.new("AAPL") + assert ticker.symbol == "AAPL" + + +@pytest.mark.asyncio +@pytest.mark.network +async def test_ticker_quote_returns_quote_with_symbol(): + ticker = await Ticker.new("AAPL") + quote = await ticker.quote() + assert quote.symbol == "AAPL" + + +@pytest.mark.asyncio +@pytest.mark.network +async def test_ticker_chart_returns_chart_with_candles(): + ticker = await Ticker.new("AAPL") + chart = await ticker.chart(Interval.OneDay, TimeRange.OneMonth) + assert len(chart.candles) > 0 + first = chart.candles[0] + assert first.open is not None + assert first.close is not None + + +@pytest.mark.asyncio +@pytest.mark.network +async def test_ticker_chart_range(): + ticker = await Ticker.new("AAPL") + chart = await ticker.chart_range(Interval.OneDay, 1700000000, 1702592000) + assert len(chart.candles) > 0 + + +@pytest.mark.asyncio +@pytest.mark.network +async def test_ticker_dividends(): + ticker = await Ticker.new("AAPL") + divs = await ticker.dividends(TimeRange.OneYear) + assert isinstance(divs, list) + + +@pytest.mark.asyncio +@pytest.mark.network +async def test_ticker_financials(): + ticker = await Ticker.new("AAPL") + fin = await ticker.financials(StatementType.Income, Frequency.Quarterly) + assert fin is not None + + +@pytest.mark.asyncio +@pytest.mark.network +async def test_ticker_news(): + ticker = await Ticker.new("AAPL") + news = await ticker.news() + assert isinstance(news, list) + + +@pytest.mark.asyncio +@pytest.mark.network +async def test_ticker_recommendations(): + ticker = await Ticker.new("AAPL") + recs = await ticker.recommendations(5) + assert recs is not None + + +def test_ticker_has_all_methods(): + """Smoke: no network. Just verify the method bindings exist.""" + expected = { + "new", "symbol", "quote", "chart", "chart_range", + "dividends", "splits", "capital_gains", + "financials", "news", "recommendations", + "edgar_submissions", + "clear_cache", "clear_quote_cache", "clear_chart_cache", + } + actual = {m for m in dir(Ticker) if not m.startswith("_")} + missing = expected - actual + assert not missing, f"missing methods: {missing}" + + +def test_ticker_has_builder(): + """Smoke: builder() exists and returns a TickerBuilder.""" + from finance_query import TickerBuilder + b = Ticker.builder("AAPL") + # Check the builder type is what we expect + assert b is not None + # Check the setter methods are callable and chain + assert hasattr(b, "lang") + assert hasattr(b, "region_code") + assert hasattr(b, "build") + + +@pytest.mark.asyncio +@pytest.mark.network +async def test_ticker_builder_chain_builds_ticker(): + ticker = await Ticker.builder("7203.T").lang("ja-JP").region_code("JP").build() + assert ticker.symbol == "7203.T" + + +def test_enable_logging_callable(): + """Smoke: enable_logging() can be called with default and explicit level.""" + import finance_query + finance_query.enable_logging() # default INFO + finance_query.enable_logging(level="DEBUG") + # Idempotent — subscriber already installed silently no-ops. + finance_query.enable_logging(level="WARN") + + +def test_enable_logging_rejects_invalid_level(): + import finance_query + import pytest + with pytest.raises(ValueError): + finance_query.enable_logging(level="BOGUS") diff --git a/finance-query-python/tests/test_tickers.py b/finance-query-python/tests/test_tickers.py new file mode 100644 index 00000000..1ef8fefd --- /dev/null +++ b/finance-query-python/tests/test_tickers.py @@ -0,0 +1,43 @@ +"""Tests for PyTickers (batch ticker surface).""" + +import pytest +from finance_query import Tickers, BatchResult, Interval, TimeRange + + +def test_tickers_class_and_batchresult_importable(): + assert Tickers is not None + assert BatchResult is not None + # Check expected methods exist + expected = {"new", "symbols", "quotes", "charts"} + actual = {m for m in dir(Tickers) if not m.startswith("_")} + assert expected.issubset(actual), f"missing: {expected - actual}" + + +@pytest.mark.asyncio +@pytest.mark.network +async def test_tickers_new_returns_tickers_with_symbols(): + tickers = await Tickers.new(["AAPL", "MSFT", "NVDA"]) + syms = tickers.symbols() + assert set(syms) == {"AAPL", "MSFT", "NVDA"} + assert len(tickers) == 3 + + +@pytest.mark.asyncio +@pytest.mark.network +async def test_tickers_quotes_returns_batch_result(): + tickers = await Tickers.new(["AAPL", "MSFT"]) + result = await tickers.quotes() + assert isinstance(result.data, dict) + assert isinstance(result.errors, dict) + # At least one symbol should resolve (no errors) — or all errored (also valid). + assert len(result.data) + len(result.errors) == 2 + + +@pytest.mark.asyncio +@pytest.mark.network +async def test_tickers_charts_returns_batch_result(): + tickers = await Tickers.new(["AAPL", "MSFT"]) + result = await tickers.charts(Interval.OneDay, TimeRange.OneMonth) + assert isinstance(result.data, dict) + assert isinstance(result.errors, dict) + assert len(result.data) + len(result.errors) == 2 diff --git a/src/constants_py.rs b/src/constants_py.rs new file mode 100644 index 00000000..7afbc9e5 --- /dev/null +++ b/src/constants_py.rs @@ -0,0 +1,192 @@ +//! Python-facing mirrors of `finance_query::constants` enums. +//! +//! Each `Py*` is registered with PyO3 as a Python enum class. The +//! `impl_enum!` macro emits the mirror + `From` conversions in both +//! directions so Rust-side calls (e.g. `ticker.chart(interval.into(), ...)`) +//! work transparently. +#![cfg(feature = "python")] +#![allow(missing_docs)] + +use crate::{ + ExchangeCode, Frequency, Industry, Interval, Region, Screener, Sector, StatementType, + TimeRange, ValueFormat, +}; +use pyo3::prelude::*; + +/// Generates a #[pyclass(eq, eq_int)] enum mirroring a Rust enum, plus +/// `From` conversions both ways. +macro_rules! impl_enum { + ($py_name:ident, $rust_name:ident, $py_str:literal, $($variant:ident),+ $(,)?) => { + #[pyclass(eq, eq_int, name = $py_str)] + #[derive(Clone, Copy, PartialEq, Eq)] + pub enum $py_name { + $($variant,)+ + } + + impl ::core::convert::From<$py_name> for $rust_name { + fn from(v: $py_name) -> Self { + match v { + $($py_name::$variant => $rust_name::$variant,)+ + } + } + } + + impl ::core::convert::From<$rust_name> for $py_name { + fn from(v: $rust_name) -> Self { + match v { + $($rust_name::$variant => $py_name::$variant,)+ + } + } + } + }; +} + +/// Same as `impl_enum!` but for source enums marked `#[non_exhaustive]`. +/// The `From for py` match needs a wildcard arm to satisfy the +/// compiler when the source enum is defined in another crate. +macro_rules! impl_enum_non_exhaustive { + ($py_name:ident, $rust_name:ident, $py_str:literal, $($variant:ident),+ $(,)?) => { + #[pyclass(eq, eq_int, name = $py_str)] + #[derive(Clone, Copy, PartialEq, Eq)] + pub enum $py_name { + $($variant,)+ + } + + impl ::core::convert::From<$py_name> for $rust_name { + fn from(v: $py_name) -> Self { + match v { + $($py_name::$variant => $rust_name::$variant,)+ + } + } + } + + impl ::core::convert::From<$rust_name> for $py_name { + fn from(v: $rust_name) -> Self { + match v { + $($rust_name::$variant => $py_name::$variant,)+ + _ => unreachable!( + "non_exhaustive source enum gained a new variant — update the Python mirror" + ), + } + } + } + }; +} + +impl_enum!( + PyInterval, Interval, "Interval", + OneMinute, FiveMinutes, FifteenMinutes, ThirtyMinutes, + OneHour, OneDay, OneWeek, OneMonth, ThreeMonths, +); + +impl_enum!( + PyTimeRange, TimeRange, "TimeRange", + OneDay, FiveDays, OneMonth, ThreeMonths, SixMonths, + OneYear, TwoYears, FiveYears, TenYears, YearToDate, Max, +); + +impl_enum!(PyFrequency, Frequency, "Frequency", Annual, Quarterly); + +impl_enum!( + PyStatementType, StatementType, "StatementType", + Income, Balance, CashFlow, +); + +impl_enum!( + PyValueFormat, ValueFormat, "ValueFormat", + Raw, Pretty, Both, +); + +impl_enum!( + PyRegion, Region, "Region", + Argentina, Australia, Brazil, Canada, China, Denmark, Finland, France, + Germany, Greece, HongKong, India, Israel, Italy, Malaysia, NewZealand, + Norway, Portugal, Russia, Singapore, Spain, Sweden, Taiwan, Thailand, + Turkey, UnitedKingdom, UnitedStates, Vietnam, +); + +impl_enum!( + PySector, Sector, "Sector", + Technology, FinancialServices, ConsumerCyclical, CommunicationServices, + Healthcare, Industrials, ConsumerDefensive, Energy, BasicMaterials, + RealEstate, Utilities, +); + +impl_enum!( + PyScreener, Screener, "Screener", + AggressiveSmallCaps, DayGainers, DayLosers, GrowthTechnologyStocks, + MostActives, MostShortedStocks, SmallCapGainers, UndervaluedGrowthStocks, + UndervaluedLargeCaps, ConservativeForeignFunds, HighYieldBond, + PortfolioAnchors, SolidLargeGrowthFunds, SolidMidcapGrowthFunds, + TopMutualFunds, +); + +impl_enum_non_exhaustive!( + PyExchangeCode, ExchangeCode, "ExchangeCode", + Ase, Bts, Ncm, Ngm, Nms, Nyq, Pcx, Pnk, Nas, + Asx, Bse, Hkg, Krx, Lse, Nsi, Shh, Shz, Tyo, Tor, Ger, +); + +impl_enum_non_exhaustive!( + PyIndustry, Industry, "Industry", + // Agriculture / Raw Materials + AgriculturalInputs, Aluminum, Coal, Copper, FarmProducts, ForestProducts, + Gold, LumberAndWoodProduction, OtherIndustrialMetalsAndMining, + OtherPreciousMetalsAndMining, Silver, Steel, ThermalCoal, Uranium, + // Consumer + ApparelManufacturing, ApparelRetail, AutoAndTruckDealerships, + AutoManufacturers, AutoParts, BeveragesBrewers, BeveragesNonAlcoholic, + BeveragesWineriesAndDistilleries, Confectioners, DepartmentStores, + DiscountStores, ElectronicGamingAndMultimedia, FoodDistribution, + FootwearAndAccessories, FurnishingsFixturesAndAppliances, Gambling, + GroceryStores, HomeImprovementRetail, HouseholdAndPersonalProducts, + InternetRetail, Leisure, Lodging, LuxuryGoods, PackagedFoods, + PersonalServices, ResidentialConstruction, ResortsAndCasinos, Restaurants, + SpecialtyRetail, TextileManufacturing, Tobacco, TravelServices, + // Energy + OilAndGasDrilling, OilAndGasEAndP, OilAndGasEquipmentAndServices, + OilAndGasIntegrated, OilAndGasMidstream, OilAndGasRefiningAndMarketing, + Solar, + // Financial Services + AssetManagement, BanksDiversified, BanksRegional, CapitalMarkets, + CreditServices, FinancialDataAndStockExchanges, InsuranceBrokers, + InsuranceDiversified, InsuranceLife, InsurancePropertyAndCasualty, + InsuranceReinsurance, InsuranceSpecialty, MortgageFinance, ShellCompanies, + // Healthcare + Biotechnology, DiagnosticsAndResearch, DrugManufacturersGeneral, + DrugManufacturersSpecialtyAndGeneric, HealthInformationServices, + HealthcarePlans, MedicalCareFacilities, MedicalDevices, MedicalDistribution, + MedicalInstrumentsAndSupplies, PharmaceuticalRetailers, + // Industrials + AerospaceAndDefense, BuildingMaterials, BuildingProductsAndEquipment, + BusinessEquipmentAndSupplies, ChemicalManufacturing, Chemicals, + Conglomerates, ConsultingServices, ElectricalEquipmentAndParts, + EngineeringAndConstruction, FarmAndHeavyConstructionMachinery, + IndustrialDistribution, InfrastructureOperations, + IntegratedFreightAndLogistics, ManufacturingDiversified, + MarinePortsAndServices, MarineShipping, MetalFabrication, + PaperAndPaperProducts, PollutionAndTreatmentControls, Railroads, + RentalAndLeasingServices, SecurityAndProtectionServices, + SpecialtyBusinessServices, SpecialtyChemicals, SpecialtyIndustrialMachinery, + StaffingAndEmploymentServices, ToolsAndAccessories, Trucking, WasteManagement, + // Real Estate + RealEstateDevelopment, RealEstateDiversified, RealEstateServices, + ReitDiversified, ReitHealthcareFacilities, ReitHotelAndMotel, ReitIndustrial, + ReitMortgage, ReitOffice, ReitResidential, ReitRetail, ReitSpecialty, + // Technology + CommunicationEquipment, ComputerHardware, ConsumerElectronics, DataAnalytics, + ElectronicComponents, ElectronicsAndComputerDistribution, + HardwareAndSoftwareDistribution, InformationTechnologyServices, + InternetContentAndInformation, ScientificAndTechnicalInstruments, + SemiconductorEquipmentAndMaterials, Semiconductors, SoftwareApplication, + SoftwareInfrastructure, + // Communication Services + Broadcasting, Entertainment, Publishing, TelecomServices, + // Utilities + UtilitiesDiversified, UtilitiesIndependentPowerProducers, + UtilitiesRegulatedElectric, UtilitiesRegulatedGas, UtilitiesRegulatedWater, + UtilitiesRenewable, + // Special + ClosedEndFundDebt, ClosedEndFundEquity, ClosedEndFundForeign, + ExchangeTradedFund, +); diff --git a/src/lib.rs b/src/lib.rs index ba312c54..0b5aaea7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -207,6 +207,92 @@ pub mod streaming; #[cfg(feature = "dataframe")] pub use finance_query_derive::ToDataFrame; +// ============================================================================ +// PyO3 bindings support (requires "python" feature) +// ============================================================================ +// Concrete Py wrappers around the generic `FormattedValue`. PyO3 cannot +// expose generic types, so we emit one wrapper per concrete instantiation. +#[cfg(feature = "python")] +pub use models::quote::formatted_value::{ + PyFormattedValueF64, PyFormattedValueI64, PyFormattedValueString, + PyFormattedValueU64, +}; + +// PyModel-generated wrapper for `Quote`. Re-exported so the python-bindings +// crate (and consumers) can reach it without traversing the private `models` +// module. +#[cfg(feature = "python")] +pub use models::quote::data::PyQuote; + +#[cfg(feature = "python")] +pub use models::chart::PyChart; + +#[cfg(feature = "python")] +pub use models::chart::{PyCapitalGain, PyDividend, PySplit}; + +#[cfg(feature = "python")] +pub use models::financials::PyFinancialStatement; + +#[cfg(feature = "python")] +pub use models::news::PyNews; + +#[cfg(feature = "python")] +pub use models::recommendation::PyRecommendation; + +#[cfg(feature = "python")] +pub use models::edgar::PyEdgarSubmissions; + +#[cfg(feature = "python")] +pub use models::sentiment::{PyFearAndGreed, PyFearGreedLabel}; + +#[cfg(feature = "python")] +pub use models::search::PySearchQuote; + +#[cfg(feature = "python")] +pub use models::screeners::{PyScreenerQuote, PyScreenerResults}; + +#[cfg(feature = "python")] +pub use models::trending::PyTrendingQuote; + +#[cfg(feature = "python")] +pub use models::lookup::{PyLookupQuote, PyLookupResults}; + +#[cfg(feature = "python")] +pub use models::market_summary::{PyMarketSummaryQuote, PySparkData}; + +#[cfg(feature = "python")] +pub use models::hours::{PyMarketHours, PyMarketTime}; + +#[cfg(feature = "python")] +pub use models::sectors::PySectorData; + +#[cfg(feature = "python")] +pub use models::currencies::PyCurrency; + +#[cfg(feature = "python")] +pub use models::industries::PyIndustryData; + +#[cfg(feature = "python")] +pub use models::exchanges::PyExchange; + +// Python-facing mirrors of the constants enums. Defined here so the +// `PyModel` derive macro can resolve `PyInterval` / `PyTimeRange` etc. via +// `use crate::{...}` from inside model files. +#[cfg(feature = "python")] +mod constants_py; + +#[cfg(feature = "python")] +pub use constants_py::{ + PyExchangeCode, PyFrequency, PyIndustry, PyInterval, PyRegion, + PyScreener, PySector, PyStatementType, PyTimeRange, PyValueFormat, +}; + +// The `PyModel` derive macro emits absolute paths like `::finance_query::PyFormattedValueF64`. +// To allow the derive to be used from within this crate itself, expose `finance_query` as +// an alias for `self` so the absolute path resolves locally. +#[cfg(feature = "python")] +extern crate self as finance_query; + // ============================================================================ // Technical Indicators (requires "indicators" feature) // ============================================================================ diff --git a/src/models/chart/candle.rs b/src/models/chart/candle.rs index 1305cf88..880a7c23 100644 --- a/src/models/chart/candle.rs +++ b/src/models/chart/candle.rs @@ -3,6 +3,9 @@ /// Contains the OHLCV candle/bar structure. use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// A single OHLCV candle/bar /// /// Note: This struct cannot be manually constructed - obtain via `Ticker::chart()`. @@ -10,6 +13,8 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] pub struct Candle { /// Timestamp (Unix) pub timestamp: i64, diff --git a/src/models/chart/data.rs b/src/models/chart/data.rs index 8041706d..42040041 100644 --- a/src/models/chart/data.rs +++ b/src/models/chart/data.rs @@ -5,6 +5,22 @@ use super::{Candle, ChartMeta}; use crate::constants::{Interval, TimeRange}; use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + +// Bring the Python enum mirrors into scope so the `PyModel` derive can resolve +// the `PyInterval` / `PyTimeRange` identifiers it emits for `Option` +// and `Option` fields. +#[cfg(feature = "python")] +#[allow(unused_imports)] +use crate::{PyInterval, PyTimeRange}; + +// Nested PyModel wrappers used by the generated `PyChart` for the `meta` and +// `candles` fields. +#[cfg(feature = "python")] +#[allow(unused_imports)] +use super::{candle::PyCandle, meta::PyChartMeta}; + /// Fully typed chart data /// /// Aggregates chart metadata and candles into a single convenient structure. @@ -14,6 +30,8 @@ use serde::{Deserialize, Serialize}; /// Note: This struct cannot be manually constructed - use `Ticker::chart()` to obtain chart data. #[non_exhaustive] #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe_from = "candles"))] pub struct Chart { /// Stock symbol pub symbol: String, diff --git a/src/models/chart/dividend_analytics.rs b/src/models/chart/dividend_analytics.rs index 8a4b4894..877a9a10 100644 --- a/src/models/chart/dividend_analytics.rs +++ b/src/models/chart/dividend_analytics.rs @@ -4,10 +4,17 @@ use serde::{Deserialize, Serialize}; use super::events::Dividend; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + +#[cfg(feature = "python")] +use super::events::PyDividend; + /// Computed analytics derived from a symbol's dividend history. /// /// Obtain via [`Ticker::dividend_analytics`](crate::Ticker::dividend_analytics). #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[non_exhaustive] pub struct DividendAnalytics { /// Total dividends paid in the requested range diff --git a/src/models/chart/events.rs b/src/models/chart/events.rs index 44df796e..3acddbbf 100644 --- a/src/models/chart/events.rs +++ b/src/models/chart/events.rs @@ -6,6 +6,9 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::OnceLock; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Chart events containing dividends, splits, and capital gains /// /// Events are deserialized from HashMaps, then lazily converted to sorted vectors @@ -72,6 +75,8 @@ pub(crate) struct CapitalGainEvent { #[non_exhaustive] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] pub struct Dividend { /// Timestamp (Unix) pub timestamp: i64, @@ -85,6 +90,8 @@ pub struct Dividend { #[non_exhaustive] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] pub struct Split { /// Timestamp (Unix) pub timestamp: i64, @@ -102,6 +109,8 @@ pub struct Split { #[non_exhaustive] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] pub struct CapitalGain { /// Timestamp (Unix) pub timestamp: i64, diff --git a/src/models/chart/meta.rs b/src/models/chart/meta.rs index b84b4972..1beca27f 100644 --- a/src/models/chart/meta.rs +++ b/src/models/chart/meta.rs @@ -3,12 +3,16 @@ /// Contains metadata about chart data including symbol, exchange, timezone, and price information. use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Metadata for chart data /// /// Note: This struct cannot be manually constructed - obtain via `Ticker::chart()`. #[non_exhaustive] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct ChartMeta { /// Stock symbol diff --git a/src/models/chart/mod.rs b/src/models/chart/mod.rs index c8dcd6e3..bb2d0b6b 100644 --- a/src/models/chart/mod.rs +++ b/src/models/chart/mod.rs @@ -2,17 +2,21 @@ //! //! Contains all data structures and types for Yahoo Finance's chart endpoint. -mod candle; +pub(crate) mod candle; mod data; pub mod dividend_analytics; pub(crate) mod events; pub(crate) mod indicators; -mod meta; +pub(crate) mod meta; pub(crate) mod response; pub(crate) mod result; pub use candle::Candle; pub use data::Chart; +#[cfg(feature = "python")] +pub use data::PyChart; pub use dividend_analytics::DividendAnalytics; pub use events::{CapitalGain, Dividend, Split}; +#[cfg(feature = "python")] +pub use events::{PyCapitalGain, PyDividend, PySplit}; pub use meta::ChartMeta; diff --git a/src/models/currencies/mod.rs b/src/models/currencies/mod.rs index 3e06bf3a..a2b5f273 100644 --- a/src/models/currencies/mod.rs +++ b/src/models/currencies/mod.rs @@ -5,3 +5,6 @@ mod response; pub use response::Currency; + +#[cfg(feature = "python")] +pub use response::PyCurrency; diff --git a/src/models/currencies/response.rs b/src/models/currencies/response.rs index 83cda05c..4a4b9a5e 100644 --- a/src/models/currencies/response.rs +++ b/src/models/currencies/response.rs @@ -4,9 +4,14 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// A single currency with its properties #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct Currency { diff --git a/src/models/edgar/cik.rs b/src/models/edgar/cik.rs index bc75e153..ababad5e 100644 --- a/src/models/edgar/cik.rs +++ b/src/models/edgar/cik.rs @@ -2,10 +2,14 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// An entry from the SEC ticker-to-CIK mapping. /// /// Maps a stock ticker symbol to its SEC CIK number and company name. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[non_exhaustive] pub struct CikEntry { /// CIK number (unique SEC identifier) diff --git a/src/models/edgar/company_facts.rs b/src/models/edgar/company_facts.rs index a931d0a6..8c8522e8 100644 --- a/src/models/edgar/company_facts.rs +++ b/src/models/edgar/company_facts.rs @@ -9,6 +9,9 @@ use serde::{Deserialize, Deserializer, Serialize}; use std::collections::HashMap; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Deserialize CIK that may come as a number or a zero-padded string. fn deserialize_cik<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { #[derive(Deserialize)] @@ -98,6 +101,10 @@ pub struct FactsByTaxonomy(pub HashMap); /// A single XBRL concept (e.g., "Revenue") with all reported values. /// /// Values are organized by unit of measure (e.g., "USD", "shares", "pure"). +// +// PyModel derive skipped: the macro does not currently support +// `HashMap>` where `T` is a nested PyModel struct (would need +// `Vec: Into>`, which isn't auto-implemented). #[derive(Debug, Clone, Serialize, Deserialize)] #[non_exhaustive] pub struct FactConcept { @@ -201,6 +208,8 @@ impl FactConcept { /// Represents one reported value from a specific filing and period. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[non_exhaustive] pub struct FactUnit { /// Start date of the reporting period (for duration facts, e.g., revenue) diff --git a/src/models/edgar/filing_index.rs b/src/models/edgar/filing_index.rs index 6860e79c..eda4f47b 100644 --- a/src/models/edgar/filing_index.rs +++ b/src/models/edgar/filing_index.rs @@ -5,8 +5,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Filing index response for a specific EDGAR accession. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[non_exhaustive] pub struct EdgarFilingIndex { /// Directory listing metadata. @@ -16,6 +20,7 @@ pub struct EdgarFilingIndex { /// Directory metadata for an EDGAR filing. #[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "python", derive(PyModel))] #[non_exhaustive] pub struct EdgarFilingIndexDirectory { /// Listing of files for the filing. @@ -25,6 +30,7 @@ pub struct EdgarFilingIndexDirectory { /// Single file entry within an EDGAR filing index. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[non_exhaustive] pub struct EdgarFilingIndexItem { /// File name (e.g., "aapl-20240928.htm"). diff --git a/src/models/edgar/mod.rs b/src/models/edgar/mod.rs index 5b84ee81..d734402f 100644 --- a/src/models/edgar/mod.rs +++ b/src/models/edgar/mod.rs @@ -19,3 +19,7 @@ pub use search::{ pub use submissions::{ EdgarFiling, EdgarFilingFile, EdgarFilingRecent, EdgarFilings, EdgarSubmissions, }; +#[cfg(feature = "python")] +pub use submissions::{ + PyEdgarFiling, PyEdgarFilingFile, PyEdgarFilingRecent, PyEdgarFilings, PyEdgarSubmissions, +}; diff --git a/src/models/edgar/search.rs b/src/models/edgar/search.rs index bd1d2449..6de62086 100644 --- a/src/models/edgar/search.rs +++ b/src/models/edgar/search.rs @@ -5,8 +5,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Full-text search results from SEC EDGAR. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[non_exhaustive] pub struct EdgarSearchResults { /// The search query that was executed (Elasticsearch query DSL, stored as raw JSON) @@ -52,6 +56,7 @@ impl EdgarSearchResults { /// Container for search hits with metadata. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[non_exhaustive] pub struct EdgarSearchHitsContainer { /// Total number of matching results @@ -69,6 +74,7 @@ pub struct EdgarSearchHitsContainer { /// Total count information for search results. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[non_exhaustive] pub struct EdgarSearchTotal { /// Total number of matching documents @@ -82,6 +88,7 @@ pub struct EdgarSearchTotal { /// A single search result hit from EDGAR full-text search (Elasticsearch format). #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[non_exhaustive] pub struct EdgarSearchHit { /// Elasticsearch index name @@ -104,6 +111,8 @@ pub struct EdgarSearchHit { /// Source data for a search hit containing the actual filing information. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[non_exhaustive] pub struct EdgarSearchSource { /// CIK numbers (as strings) diff --git a/src/models/edgar/submissions.rs b/src/models/edgar/submissions.rs index 0a415e1e..8bb74953 100644 --- a/src/models/edgar/submissions.rs +++ b/src/models/edgar/submissions.rs @@ -5,6 +5,9 @@ use serde::{Deserialize, Deserializer, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Deserialize empty strings as None fn deserialize_empty_string_as_none<'de, D>(deserializer: D) -> Result, D::Error> where @@ -28,6 +31,7 @@ where /// Contains company metadata and filing history. The `filings` field holds /// the most recent ~1000 filings inline, with links to older history files. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct EdgarSubmissions { @@ -94,6 +98,7 @@ pub struct EdgarSubmissions { /// Container for recent filings and links to older filing history files. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct EdgarFilings { @@ -108,6 +113,7 @@ pub struct EdgarFilings { /// Reference to an additional filing history file for older filings. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct EdgarFilingFile { @@ -133,6 +139,7 @@ pub struct EdgarFilingFile { /// EDGAR returns filing data as parallel arrays (each field is a `Vec` of the same length). /// Use [`to_filings()`](EdgarFilingRecent::to_filings) to convert to a `Vec`. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct EdgarFilingRecent { @@ -251,6 +258,8 @@ impl EdgarFilingRecent { /// [`to_filings()`](EdgarFilingRecent::to_filings). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[non_exhaustive] pub struct EdgarFiling { /// Accession number (unique filing identifier, e.g., "0000320193-24-000123") diff --git a/src/models/exchanges.rs b/src/models/exchanges.rs index 49212dff..30dfbda4 100644 --- a/src/models/exchanges.rs +++ b/src/models/exchanges.rs @@ -2,10 +2,15 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Information about a supported exchange. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] pub struct Exchange { /// Country or region where the exchange operates. pub country: String, diff --git a/src/models/financials/mod.rs b/src/models/financials/mod.rs index ba4d56a5..0ce75666 100644 --- a/src/models/financials/mod.rs +++ b/src/models/financials/mod.rs @@ -6,3 +6,5 @@ mod response; pub use response::FinancialStatement; +#[cfg(feature = "python")] +pub use response::PyFinancialStatement; diff --git a/src/models/financials/response.rs b/src/models/financials/response.rs index 0f3ae09a..ab5de374 100644 --- a/src/models/financials/response.rs +++ b/src/models/financials/response.rs @@ -7,6 +7,9 @@ use crate::error::Result; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Raw response structure from Yahoo Finance fundamentals-timeseries API #[derive(Debug, Clone, Deserialize)] struct RawTimeseriesResponse { @@ -50,6 +53,7 @@ struct RawMeta { /// /// This matches the Python finance-query API response format. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct FinancialStatement { /// Stock symbol diff --git a/src/models/hours/mod.rs b/src/models/hours/mod.rs index c06d3148..db4bd1d5 100644 --- a/src/models/hours/mod.rs +++ b/src/models/hours/mod.rs @@ -28,3 +28,6 @@ mod response; pub use response::{MarketHours, MarketTime}; + +#[cfg(feature = "python")] +pub use response::{PyMarketHours, PyMarketTime}; diff --git a/src/models/hours/response.rs b/src/models/hours/response.rs index 12d37132..8f975d82 100644 --- a/src/models/hours/response.rs +++ b/src/models/hours/response.rs @@ -1,5 +1,8 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + // ============================================================================ // Raw Yahoo Finance response structures (internal) // ============================================================================ @@ -61,6 +64,8 @@ struct RawTimezone { /// Market time information for a specific market #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct MarketTime { @@ -108,6 +113,7 @@ pub struct MarketTime { /// Flattened response for market hours #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct MarketHours { diff --git a/src/models/industries/mod.rs b/src/models/industries/mod.rs index f2cc8117..499e3290 100644 --- a/src/models/industries/mod.rs +++ b/src/models/industries/mod.rs @@ -3,3 +3,9 @@ mod response; pub use response::IndustryData; + +#[cfg(feature = "python")] +pub use response::{ + PyBenchmarkPerformance, PyGrowthCompany, PyIndustryCompany, PyIndustryData, + PyIndustryOverview, PyIndustryPerformance, PyPerformingCompany, PyResearchReport, +}; diff --git a/src/models/industries/response.rs b/src/models/industries/response.rs index 9ac75750..f66d5d0d 100644 --- a/src/models/industries/response.rs +++ b/src/models/industries/response.rs @@ -1,6 +1,9 @@ use crate::models::quote::FormattedValue; use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + // ============================================================================ // Raw response structs (private) - for parsing Yahoo's nested structure // ============================================================================ @@ -118,6 +121,7 @@ struct RawResearchReport { /// Industry data from Yahoo Finance #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct IndustryData { @@ -160,6 +164,8 @@ pub struct IndustryData { /// Industry overview statistics #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct IndustryOverview { @@ -183,6 +189,8 @@ pub struct IndustryOverview { /// Industry performance metrics #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct IndustryPerformance { @@ -206,6 +214,8 @@ pub struct IndustryPerformance { /// Benchmark performance for comparison #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct BenchmarkPerformance { @@ -232,6 +242,8 @@ pub struct BenchmarkPerformance { /// Company within an industry #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct IndustryCompany { @@ -266,6 +278,8 @@ pub struct IndustryCompany { /// Top performing company by YTD return #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct PerformingCompany { @@ -288,6 +302,8 @@ pub struct PerformingCompany { /// Top growth company by growth estimate #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct GrowthCompany { @@ -310,6 +326,8 @@ pub struct GrowthCompany { /// Research report #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct ResearchReport { diff --git a/src/models/lookup/mod.rs b/src/models/lookup/mod.rs index 6e6af960..426b2f67 100644 --- a/src/models/lookup/mod.rs +++ b/src/models/lookup/mod.rs @@ -8,3 +8,8 @@ mod response; pub use quote::LookupQuote; pub use response::LookupResults; + +#[cfg(feature = "python")] +pub use quote::PyLookupQuote; +#[cfg(feature = "python")] +pub use response::PyLookupResults; diff --git a/src/models/lookup/quote.rs b/src/models/lookup/quote.rs index 4d675572..2f1647be 100644 --- a/src/models/lookup/quote.rs +++ b/src/models/lookup/quote.rs @@ -4,9 +4,14 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// A quote/document result from symbol lookup #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[non_exhaustive] #[serde(rename_all = "camelCase")] pub struct LookupQuote { diff --git a/src/models/lookup/response.rs b/src/models/lookup/response.rs index 82d9f1c6..594ecc4c 100644 --- a/src/models/lookup/response.rs +++ b/src/models/lookup/response.rs @@ -5,6 +5,11 @@ use super::LookupQuote; use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; +#[cfg(feature = "python")] +use super::quote::PyLookupQuote; + /// Raw response wrapper from Yahoo Finance lookup endpoint #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -30,6 +35,7 @@ struct RawLookupResult { /// Response wrapper for lookup endpoint #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[non_exhaustive] #[serde(rename_all = "camelCase")] pub struct LookupResults { diff --git a/src/models/market_summary/mod.rs b/src/models/market_summary/mod.rs index fbf2ddac..91e41fb3 100644 --- a/src/models/market_summary/mod.rs +++ b/src/models/market_summary/mod.rs @@ -5,3 +5,6 @@ mod response; pub use response::{MarketSummaryQuote, SparkData}; + +#[cfg(feature = "python")] +pub use response::{PyMarketSummaryQuote, PySparkData}; diff --git a/src/models/market_summary/response.rs b/src/models/market_summary/response.rs index 9152ae50..b822e4f1 100644 --- a/src/models/market_summary/response.rs +++ b/src/models/market_summary/response.rs @@ -5,9 +5,14 @@ use crate::models::quote::FormattedValue; use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// A single market summary quote (index, currency, commodity, etc.) #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct MarketSummaryQuote { @@ -45,6 +50,7 @@ pub struct MarketSummaryQuote { /// Spark chart mini-data for market summary #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct SparkData { /// Close prices diff --git a/src/models/news/mod.rs b/src/models/news/mod.rs index a8c3b244..361ee81b 100644 --- a/src/models/news/mod.rs +++ b/src/models/news/mod.rs @@ -3,3 +3,5 @@ mod scraped; pub use scraped::News; +#[cfg(feature = "python")] +pub use scraped::PyNews; diff --git a/src/models/news/scraped.rs b/src/models/news/scraped.rs index 8b3b9c50..866a46bf 100644 --- a/src/models/news/scraped.rs +++ b/src/models/news/scraped.rs @@ -2,9 +2,14 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// A news article #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[non_exhaustive] pub struct News { /// Article title diff --git a/src/models/options/chain.rs b/src/models/options/chain.rs index 40e6127d..7beb59d8 100644 --- a/src/models/options/chain.rs +++ b/src/models/options/chain.rs @@ -1,11 +1,17 @@ use super::contract::OptionContract; use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; +#[cfg(feature = "python")] +use super::contract::PyOptionContract; + /// Options chain data for a specific expiration /// /// Note: This struct cannot be manually constructed - use `Ticker::options()` to obtain options data. #[non_exhaustive] #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct OptionChain { /// Expiration date (Unix timestamp) @@ -27,6 +33,8 @@ pub struct OptionChain { #[non_exhaustive] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] pub struct OptionsQuote { /// Symbol diff --git a/src/models/options/contract.rs b/src/models/options/contract.rs index 72a046b4..0645e808 100644 --- a/src/models/options/contract.rs +++ b/src/models/options/contract.rs @@ -1,6 +1,9 @@ use serde::{Deserialize, Serialize}; use std::ops::Deref; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// A collection of option contracts with DataFrame support. /// /// This wrapper allows `options.calls.to_dataframe()` syntax while still @@ -49,6 +52,8 @@ impl Contracts { #[non_exhaustive] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] pub struct OptionContract { /// Contract symbol (e.g., "AAPL250117C00150000") diff --git a/src/models/quote/asset_profile.rs b/src/models/quote/asset_profile.rs index b697276c..ca1cfc04 100644 --- a/src/models/quote/asset_profile.rs +++ b/src/models/quote/asset_profile.rs @@ -4,8 +4,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Company asset profile and information #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct AssetProfile { /// Street address line 1 @@ -119,6 +123,7 @@ pub struct AssetProfile { /// Company officer information #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct CompanyOfficer { /// Maximum age of this data in seconds diff --git a/src/models/quote/balance_sheet_history.rs b/src/models/quote/balance_sheet_history.rs index 1ab5cd2d..3a41262e 100644 --- a/src/models/quote/balance_sheet_history.rs +++ b/src/models/quote/balance_sheet_history.rs @@ -1,8 +1,12 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Balance sheet history (annual statements) #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct BalanceSheetHistory { /// List of annual balance sheet statements @@ -16,6 +20,7 @@ pub struct BalanceSheetHistory { /// Balance sheet history (quarterly statements) #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct BalanceSheetHistoryQuarterly { /// List of quarterly balance sheet statements diff --git a/src/models/quote/calendar_events.rs b/src/models/quote/calendar_events.rs index 5a1eac92..2c195e01 100644 --- a/src/models/quote/calendar_events.rs +++ b/src/models/quote/calendar_events.rs @@ -2,8 +2,12 @@ use serde::{Deserialize, Serialize}; use super::FormattedValue; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Calendar events including earnings and dividend dates #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct CalendarEvents { /// Maximum age of the data in seconds @@ -25,6 +29,7 @@ pub struct CalendarEvents { /// Earnings calendar information #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct EarningsCalendar { /// List of earnings dates (usually contains 1-2 dates) diff --git a/src/models/quote/cashflow_statement_history.rs b/src/models/quote/cashflow_statement_history.rs index b3ff49a3..370341e4 100644 --- a/src/models/quote/cashflow_statement_history.rs +++ b/src/models/quote/cashflow_statement_history.rs @@ -1,8 +1,12 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Cash flow statement history (annual statements) #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct CashflowStatementHistory { /// List of annual cash flow statements @@ -16,6 +20,7 @@ pub struct CashflowStatementHistory { /// Cash flow statement history (quarterly statements) #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct CashflowStatementHistoryQuarterly { /// List of quarterly cash flow statements diff --git a/src/models/quote/data.rs b/src/models/quote/data.rs index 3339b7fb..fc99ddc5 100644 --- a/src/models/quote/data.rs +++ b/src/models/quote/data.rs @@ -14,6 +14,61 @@ use super::{ UpgradeDowngradeHistory, }; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + +// PyModel-generated wrapper types for nested fields. These are emitted in the +// same modules as their inner types; we import them here so that PyQuote's +// getters (which call them by their unqualified name) resolve. +#[cfg(feature = "python")] +use super::asset_profile::PyCompanyOfficer; +#[cfg(feature = "python")] +use super::balance_sheet_history::{PyBalanceSheetHistory, PyBalanceSheetHistoryQuarterly}; +#[cfg(feature = "python")] +use super::calendar_events::PyCalendarEvents; +#[cfg(feature = "python")] +use super::cashflow_statement_history::{ + PyCashflowStatementHistory, PyCashflowStatementHistoryQuarterly, +}; +#[cfg(feature = "python")] +use super::earnings::PyEarnings; +#[cfg(feature = "python")] +use super::earnings_history::PyEarningsHistory; +#[cfg(feature = "python")] +use super::earnings_trend::PyEarningsTrend; +#[cfg(feature = "python")] +use super::equity_performance::PyEquityPerformance; +#[cfg(feature = "python")] +use super::fund_ownership::PyFundOwnership; +#[cfg(feature = "python")] +use super::fund_performance::PyFundPerformance; +#[cfg(feature = "python")] +use super::fund_profile::PyFundProfile; +#[cfg(feature = "python")] +use super::income_statement_history::{ + PyIncomeStatementHistory, PyIncomeStatementHistoryQuarterly, +}; +#[cfg(feature = "python")] +use super::index_trend::{PyIndexTrend, PyIndustryTrend, PySectorTrend}; +#[cfg(feature = "python")] +use super::insider_holders::PyInsiderHolders; +#[cfg(feature = "python")] +use super::insider_transactions::PyInsiderTransactions; +#[cfg(feature = "python")] +use super::institution_ownership::PyInstitutionOwnership; +#[cfg(feature = "python")] +use super::major_holders_breakdown::PyMajorHoldersBreakdown; +#[cfg(feature = "python")] +use super::net_share_purchase_activity::PyNetSharePurchaseActivity; +#[cfg(feature = "python")] +use super::recommendation_trend::PyRecommendationTrend; +#[cfg(feature = "python")] +use super::sec_filings::PySecFilings; +#[cfg(feature = "python")] +use super::top_holdings::PyTopHoldings; +#[cfg(feature = "python")] +use super::upgrade_downgrade_history::PyUpgradeDowngradeHistory; + /// Flattened quote data with deduplicated fields /// /// This is the primary data structure for stock quotes. It flattens scalar fields @@ -50,6 +105,7 @@ use super::{ /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct Quote { diff --git a/src/models/quote/default_key_statistics.rs b/src/models/quote/default_key_statistics.rs index e4f165f8..41b70457 100644 --- a/src/models/quote/default_key_statistics.rs +++ b/src/models/quote/default_key_statistics.rs @@ -5,10 +5,14 @@ use super::formatted_value::FormattedValue; use serde::{Deserialize, Serialize}; use serde_json::Value; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Default key statistics for a symbol /// /// Contains extensive statistical data including valuation metrics, share data, and financial ratios. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct DefaultKeyStatistics { /// 52-week price change percentage diff --git a/src/models/quote/earnings.rs b/src/models/quote/earnings.rs index 946f50f2..a612cf76 100644 --- a/src/models/quote/earnings.rs +++ b/src/models/quote/earnings.rs @@ -4,8 +4,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Earnings data including charts and forecasts #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct Earnings { /// Default methodology (e.g., "gaap") @@ -23,6 +27,7 @@ pub struct Earnings { /// Earnings chart showing quarterly data #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct EarningsChart { /// Quarterly earnings data @@ -60,6 +65,7 @@ pub struct EarningsChart { /// Quarterly earnings entry #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct QuarterlyEarnings { /// Date/quarter identifier @@ -93,6 +99,7 @@ pub struct QuarterlyEarnings { /// Financial chart showing revenue and earnings over time #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct FinancialsChart { /// Yearly financial data @@ -106,6 +113,7 @@ pub struct FinancialsChart { /// Yearly financial data entry #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct YearlyFinancials { /// Date @@ -123,6 +131,7 @@ pub struct YearlyFinancials { /// Quarterly financial data entry #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct QuarterlyFinancials { /// Date diff --git a/src/models/quote/earnings_history.rs b/src/models/quote/earnings_history.rs index bce15079..45b9ad9a 100644 --- a/src/models/quote/earnings_history.rs +++ b/src/models/quote/earnings_history.rs @@ -4,8 +4,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Historical earnings data #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct EarningsHistory { /// Default methodology (e.g., "gaap") @@ -19,6 +23,7 @@ pub struct EarningsHistory { /// Single historical earnings entry #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct EarningsHistoryEntry { /// Maximum age of this data in seconds diff --git a/src/models/quote/earnings_trend.rs b/src/models/quote/earnings_trend.rs index f5845f09..9a9e9ed6 100644 --- a/src/models/quote/earnings_trend.rs +++ b/src/models/quote/earnings_trend.rs @@ -4,8 +4,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Earnings trend and estimates #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct EarningsTrend { /// Default methodology (e.g., "gaap") @@ -23,6 +27,7 @@ pub struct EarningsTrend { /// Earnings trend for a specific period #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct EarningsTrendPeriod { /// End date for this period @@ -48,6 +53,7 @@ pub struct EarningsTrendPeriod { /// Earnings estimate data #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct EarningsEstimate { /// Average estimate @@ -81,6 +87,7 @@ pub struct EarningsEstimate { /// Revenue estimate data #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct RevenueEstimate { /// Average estimate @@ -110,6 +117,7 @@ pub struct RevenueEstimate { /// EPS trend over time #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct EpsTrend { /// Current EPS @@ -139,6 +147,7 @@ pub struct EpsTrend { /// EPS revision data #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct EpsRevisions { /// Number of upward revisions in last 7 days diff --git a/src/models/quote/equity_performance.rs b/src/models/quote/equity_performance.rs index 72daf6eb..02c40d30 100644 --- a/src/models/quote/equity_performance.rs +++ b/src/models/quote/equity_performance.rs @@ -5,8 +5,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Equity performance data comparing stock returns to benchmark #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct EquityPerformance { /// Maximum age of the data in seconds @@ -28,6 +32,7 @@ pub struct EquityPerformance { /// Benchmark information #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct Benchmark { /// Benchmark symbol (e.g., "^GSPC" for S&P 500) @@ -41,6 +46,8 @@ pub struct Benchmark { /// Performance metrics across multiple time periods #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(rename = "EquityPerformanceOverview"))] #[serde(rename_all = "camelCase")] pub struct PerformanceOverview { /// Date the performance data is as of (Unix timestamp) diff --git a/src/models/quote/financial_data.rs b/src/models/quote/financial_data.rs index 185a3c8b..0a56b019 100644 --- a/src/models/quote/financial_data.rs +++ b/src/models/quote/financial_data.rs @@ -4,10 +4,14 @@ use super::formatted_value::FormattedValue; /// Contains key financial metrics and ratios for the company. use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Financial data and key metrics /// /// Contains financial ratios, margins, cash flow, and analyst recommendations. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct FinancialData { /// Current stock price diff --git a/src/models/quote/formatted_value.rs b/src/models/quote/formatted_value.rs index 1802eefd..5363e8ec 100644 --- a/src/models/quote/formatted_value.rs +++ b/src/models/quote/formatted_value.rs @@ -83,6 +83,78 @@ impl FormattedValue { } } +#[cfg(feature = "python")] +mod py { + //! Concrete PyO3 wrappers around `FormattedValue`. PyO3 doesn't support + //! generic `#[pyclass]`, so we emit one per concrete T. + //! + //! Naming convention: `FormattedValue` + suffix matching the Rust type. + //! Python sees `FormattedValueF64`, `FormattedValueI64`, etc. + + use super::FormattedValue; + use pyo3::prelude::*; + + macro_rules! impl_formatted_value_py { + ($py_ty:ident, $rust_inner:ty, $py_name:literal) => { + #[pyclass(frozen, name = $py_name)] + #[derive(Clone)] + pub struct $py_ty { + inner: ::std::sync::Arc>, + } + + #[pymethods] + impl $py_ty { + #[getter] + fn raw(&self) -> Option<$rust_inner> { + self.inner.raw.clone() + } + + #[getter] + fn fmt(&self) -> Option { + self.inner.fmt.clone() + } + + #[getter] + fn long_fmt(&self) -> Option { + self.inner.long_fmt.clone() + } + + fn __repr__(&self) -> String { + format!("{:?}", *self.inner) + } + } + + impl ::core::convert::From> for $py_ty { + fn from(value: FormattedValue<$rust_inner>) -> Self { + Self { inner: ::std::sync::Arc::new(value) } + } + } + + impl ::core::convert::From<$py_ty> for FormattedValue<$rust_inner> { + fn from(value: $py_ty) -> Self { + let inner = value.inner; + FormattedValue { + fmt: inner.fmt.clone(), + long_fmt: inner.long_fmt.clone(), + raw: inner.raw.clone(), + } + } + } + }; + } + + impl_formatted_value_py!(PyFormattedValueF64, f64, "FormattedValueF64"); + impl_formatted_value_py!(PyFormattedValueI64, i64, "FormattedValueI64"); + impl_formatted_value_py!(PyFormattedValueU64, u64, "FormattedValueU64"); + impl_formatted_value_py!(PyFormattedValueString, String, "FormattedValueString"); +} + +#[cfg(feature = "python")] +pub use py::{ + PyFormattedValueF64, PyFormattedValueI64, PyFormattedValueString, + PyFormattedValueU64, +}; + #[cfg(test)] mod tests { use super::*; diff --git a/src/models/quote/fund_ownership.rs b/src/models/quote/fund_ownership.rs index 05f72d7b..35094386 100644 --- a/src/models/quote/fund_ownership.rs +++ b/src/models/quote/fund_ownership.rs @@ -4,8 +4,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Fund ownership data #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct FundOwnership { /// Maximum age of this data in seconds @@ -19,6 +23,7 @@ pub struct FundOwnership { /// Individual fund owner #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct FundOwner { /// Maximum age of this data in seconds diff --git a/src/models/quote/fund_performance.rs b/src/models/quote/fund_performance.rs index a7d17679..a781e34d 100644 --- a/src/models/quote/fund_performance.rs +++ b/src/models/quote/fund_performance.rs @@ -2,8 +2,12 @@ use serde::{Deserialize, Serialize}; use super::FormattedValue; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Fund performance data including returns, risk metrics, and historical performance #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct FundPerformance { /// Maximum age of the data in seconds @@ -53,6 +57,8 @@ pub struct FundPerformance { /// Performance overview with key return metrics #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(rename = "FundPerformanceOverview"))] #[serde(rename_all = "camelCase")] pub struct PerformanceOverview { /// As of date (Unix timestamp) @@ -78,6 +84,7 @@ pub struct PerformanceOverview { /// Category average performance overview #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct PerformanceOverviewCat { /// Year-to-date return percentage (category average) @@ -99,6 +106,7 @@ pub struct PerformanceOverviewCat { /// Trailing returns at market price #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct TrailingReturns { /// As of date (Unix timestamp) @@ -144,6 +152,7 @@ pub struct TrailingReturns { /// Trailing returns at NAV (Net Asset Value) #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct TrailingReturnsNav { /// Year-to-date return @@ -177,6 +186,7 @@ pub struct TrailingReturnsNav { /// Category average trailing returns #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct TrailingReturnsCat { /// Year-to-date return (category average) @@ -218,6 +228,7 @@ pub struct TrailingReturnsCat { /// Annual total returns by year #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct AnnualTotalReturns { /// Annual returns for this fund @@ -231,6 +242,7 @@ pub struct AnnualTotalReturns { /// Single year's return data #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct AnnualReturn { /// Year (e.g., "2024") @@ -244,6 +256,7 @@ pub struct AnnualReturn { /// Past quarterly returns #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct PastQuarterlyReturns { /// Quarterly returns @@ -253,6 +266,7 @@ pub struct PastQuarterlyReturns { /// Risk overview statistics #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct RiskOverviewStatistics { /// Risk statistics for various time periods @@ -262,6 +276,7 @@ pub struct RiskOverviewStatistics { /// Category average risk overview statistics #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct RiskOverviewStatisticsCat { /// Category average risk statistics @@ -271,6 +286,7 @@ pub struct RiskOverviewStatisticsCat { /// Risk statistics for a specific time period #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct RiskStatistic { /// Time period (e.g., "3y", "5y", "10y") diff --git a/src/models/quote/fund_profile.rs b/src/models/quote/fund_profile.rs index 86e19fbf..e3b7a9af 100644 --- a/src/models/quote/fund_profile.rs +++ b/src/models/quote/fund_profile.rs @@ -2,8 +2,12 @@ use serde::{Deserialize, Serialize}; use super::FormattedValue; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Fund profile information including management, fees, and expenses #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct FundProfile { /// Maximum age of the data in seconds @@ -45,6 +49,7 @@ pub struct FundProfile { /// Fund management information #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct ManagementInfo { /// Name of the fund manager @@ -58,6 +63,7 @@ pub struct ManagementInfo { /// Fees and expenses for a fund #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct FeesExpenses { /// Annual expense ratio from the latest report @@ -79,6 +85,7 @@ pub struct FeesExpenses { /// Average fees and expenses for funds in the same category #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct FeesExpensesCat { /// Category average annual expense ratio diff --git a/src/models/quote/income_statement_history.rs b/src/models/quote/income_statement_history.rs index 23ea6068..d32851c1 100644 --- a/src/models/quote/income_statement_history.rs +++ b/src/models/quote/income_statement_history.rs @@ -1,8 +1,12 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Income statement history (annual statements) #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct IncomeStatementHistory { /// List of annual income statements @@ -16,6 +20,7 @@ pub struct IncomeStatementHistory { /// Income statement history (quarterly statements) #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct IncomeStatementHistoryQuarterly { /// List of quarterly income statements diff --git a/src/models/quote/index_trend.rs b/src/models/quote/index_trend.rs index e75a30f6..962993a0 100644 --- a/src/models/quote/index_trend.rs +++ b/src/models/quote/index_trend.rs @@ -1,8 +1,12 @@ use super::FormattedValue; use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Index trend data (growth estimates for the index) #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct IndexTrend { /// Maximum age of the data in seconds @@ -28,6 +32,7 @@ pub struct IndexTrend { /// Industry trend data #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct IndustryTrend { /// Maximum age of the data in seconds @@ -53,6 +58,7 @@ pub struct IndustryTrend { /// Sector trend data #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct SectorTrend { /// Maximum age of the data in seconds @@ -78,6 +84,7 @@ pub struct SectorTrend { /// Growth estimate for a specific period #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct TrendEstimate { /// Period (e.g., "0q", "+1q", "0y", "+1y", "LTG") diff --git a/src/models/quote/insider_holders.rs b/src/models/quote/insider_holders.rs index 7fa342cd..f9d03d98 100644 --- a/src/models/quote/insider_holders.rs +++ b/src/models/quote/insider_holders.rs @@ -4,8 +4,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Insider holders data #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct InsiderHolders { /// List of insider holders @@ -15,6 +19,7 @@ pub struct InsiderHolders { /// Individual insider holder information #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct InsiderHolder { /// Maximum age of this data in seconds diff --git a/src/models/quote/insider_transactions.rs b/src/models/quote/insider_transactions.rs index da409382..dfc2f41d 100644 --- a/src/models/quote/insider_transactions.rs +++ b/src/models/quote/insider_transactions.rs @@ -4,8 +4,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Insider transaction history #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct InsiderTransactions { /// Maximum age of this data in seconds @@ -19,6 +23,7 @@ pub struct InsiderTransactions { /// Individual insider transaction #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct InsiderTransaction { /// Maximum age of this data in seconds diff --git a/src/models/quote/institution_ownership.rs b/src/models/quote/institution_ownership.rs index d18f04e5..2df5d9cc 100644 --- a/src/models/quote/institution_ownership.rs +++ b/src/models/quote/institution_ownership.rs @@ -4,8 +4,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Institutional ownership data #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct InstitutionOwnership { /// Maximum age of this data in seconds @@ -19,6 +23,7 @@ pub struct InstitutionOwnership { /// Individual institutional owner #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct InstitutionOwner { /// Maximum age of this data in seconds diff --git a/src/models/quote/major_holders_breakdown.rs b/src/models/quote/major_holders_breakdown.rs index 3271e61e..9bf31044 100644 --- a/src/models/quote/major_holders_breakdown.rs +++ b/src/models/quote/major_holders_breakdown.rs @@ -4,8 +4,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Breakdown of ownership by different types of holders #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct MajorHoldersBreakdown { /// Percentage of shares held by insiders diff --git a/src/models/quote/net_share_purchase_activity.rs b/src/models/quote/net_share_purchase_activity.rs index a48e9cc5..6dab1472 100644 --- a/src/models/quote/net_share_purchase_activity.rs +++ b/src/models/quote/net_share_purchase_activity.rs @@ -4,8 +4,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Net share purchase activity by insiders #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct NetSharePurchaseActivity { /// Time period for this activity (e.g., "6m") diff --git a/src/models/quote/price.rs b/src/models/quote/price.rs index 72115551..70a78476 100644 --- a/src/models/quote/price.rs +++ b/src/models/quote/price.rs @@ -5,10 +5,14 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Detailed pricing data for a stock /// /// Includes current price, pre/post market data, volume, market cap, and exchange information. #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] #[derive(Default)] pub struct Price { diff --git a/src/models/quote/quote_type.rs b/src/models/quote/quote_type.rs index 5ee34825..5e815bff 100644 --- a/src/models/quote/quote_type.rs +++ b/src/models/quote/quote_type.rs @@ -3,6 +3,9 @@ /// Contains metadata about the symbol including exchange, type, and timezone information. use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Response wrapper for quote type endpoint #[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] @@ -58,6 +61,7 @@ pub(crate) struct QuoteTypeResult { /// /// Contains exchange information, company names, timezone data, and other metadata. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct QuoteTypeData { /// Exchange code (e.g., "NMS", "NYQ") diff --git a/src/models/quote/recommendation_trend.rs b/src/models/quote/recommendation_trend.rs index 8505015f..a3181948 100644 --- a/src/models/quote/recommendation_trend.rs +++ b/src/models/quote/recommendation_trend.rs @@ -4,8 +4,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Analyst recommendation trends #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct RecommendationTrend { /// List of recommendation trends by period @@ -19,6 +23,7 @@ pub struct RecommendationTrend { /// Recommendations for a specific time period #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct RecommendationPeriod { /// Time period (e.g., "0m", "-1m", "-2m") diff --git a/src/models/quote/sec_filings.rs b/src/models/quote/sec_filings.rs index 4a9ffb7d..4602745d 100644 --- a/src/models/quote/sec_filings.rs +++ b/src/models/quote/sec_filings.rs @@ -4,8 +4,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// SEC filings data #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct SecFilings { /// Maximum age of this data in seconds @@ -19,6 +23,7 @@ pub struct SecFilings { /// Individual SEC filing #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct SecFiling { /// Maximum age of this data in seconds @@ -53,6 +58,7 @@ pub struct SecFiling { /// SEC filing exhibit #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct SecExhibit { /// Type/number of exhibit (e.g., "EX-21.1", "10-K") diff --git a/src/models/quote/summary_detail.rs b/src/models/quote/summary_detail.rs index f17f7185..2533b61d 100644 --- a/src/models/quote/summary_detail.rs +++ b/src/models/quote/summary_detail.rs @@ -5,10 +5,14 @@ use super::formatted_value::FormattedValue; use serde::{Deserialize, Serialize}; use serde_json::Value; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Summary detail trading and valuation metrics /// /// Contains detailed information about price, volume, market cap, and other trading data. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct SummaryDetail { /// Algorithm (for crypto/special assets) diff --git a/src/models/quote/summary_profile.rs b/src/models/quote/summary_profile.rs index 853b2db1..92a0d427 100644 --- a/src/models/quote/summary_profile.rs +++ b/src/models/quote/summary_profile.rs @@ -4,10 +4,14 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Company profile information /// /// Contains address, contact information, sector, industry, and business description. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct SummaryProfile { /// Street address line 1 diff --git a/src/models/quote/top_holdings.rs b/src/models/quote/top_holdings.rs index bf22e5a0..7344a785 100644 --- a/src/models/quote/top_holdings.rs +++ b/src/models/quote/top_holdings.rs @@ -2,6 +2,9 @@ use serde::{Deserialize, Deserializer, Serialize}; use super::FormattedValue; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Custom deserializer that flattens Yahoo's array of single-sector objects into one struct fn deserialize_sector_weightings<'de, D>( deserializer: D, @@ -15,6 +18,7 @@ where /// Fund holdings including asset allocation, top holdings, and sector weightings #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct TopHoldings { /// Maximum age of the data in seconds @@ -69,6 +73,7 @@ pub struct TopHoldings { /// Individual holding in the fund #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct Holding { /// Stock symbol @@ -86,6 +91,7 @@ pub struct Holding { /// Equity holdings valuation metrics #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct EquityHoldings { /// Price to earnings ratio @@ -107,6 +113,7 @@ pub struct EquityHoldings { /// Bond rating distribution #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] pub struct BondRating { /// US Government bonds percentage #[serde(default)] @@ -151,6 +158,7 @@ pub struct BondRating { /// Sector weighting distribution (single sector from Yahoo's array format) #[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "python", derive(PyModel))] pub struct SectorWeighting { /// Real estate sector percentage #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/models/quote/upgrade_downgrade_history.rs b/src/models/quote/upgrade_downgrade_history.rs index 49976e0d..48aca90b 100644 --- a/src/models/quote/upgrade_downgrade_history.rs +++ b/src/models/quote/upgrade_downgrade_history.rs @@ -4,8 +4,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Analyst upgrade/downgrade history #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct UpgradeDowngradeHistory { /// List of rating changes @@ -19,6 +23,7 @@ pub struct UpgradeDowngradeHistory { /// Individual analyst rating change #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct GradeChange { /// Timestamp of the grade change (epoch) diff --git a/src/models/recommendation/data.rs b/src/models/recommendation/data.rs index fe912d64..a6e2307e 100644 --- a/src/models/recommendation/data.rs +++ b/src/models/recommendation/data.rs @@ -4,6 +4,11 @@ use super::SimilarSymbol; /// Contains the fully typed Recommendation structure for similar/recommended symbols. use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; +#[cfg(feature = "python")] +use super::symbol::PySimilarSymbol; + /// Fully typed recommendation data /// /// Aggregates the queried symbol and its recommendations into a single @@ -13,6 +18,7 @@ use serde::{Deserialize, Serialize}; /// Note: This struct cannot be manually constructed - use `Ticker::recommendations()` to obtain recommendations. #[non_exhaustive] #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] pub struct Recommendation { /// Symbol that was queried pub symbol: String, diff --git a/src/models/recommendation/mod.rs b/src/models/recommendation/mod.rs index 4ce50213..8552472e 100644 --- a/src/models/recommendation/mod.rs +++ b/src/models/recommendation/mod.rs @@ -8,4 +8,6 @@ pub(crate) mod result; mod symbol; pub use data::Recommendation; +#[cfg(feature = "python")] +pub use data::PyRecommendation; pub use symbol::SimilarSymbol; diff --git a/src/models/recommendation/symbol.rs b/src/models/recommendation/symbol.rs index 498a8554..b20a1481 100644 --- a/src/models/recommendation/symbol.rs +++ b/src/models/recommendation/symbol.rs @@ -3,12 +3,17 @@ /// Contains the SimilarSymbol type representing a recommended symbol. use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// A similar/recommended symbol with score /// /// Note: This struct cannot be manually constructed - obtain via `Ticker::recommendations()`. #[non_exhaustive] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] pub struct SimilarSymbol { /// Stock symbol pub symbol: String, diff --git a/src/models/screeners/mod.rs b/src/models/screeners/mod.rs index 616fbc58..dd1b7fd8 100644 --- a/src/models/screeners/mod.rs +++ b/src/models/screeners/mod.rs @@ -43,4 +43,9 @@ pub use fields::{EquityField, FundField}; pub use query::{EquityScreenerQuery, FundScreenerQuery, QuoteType, ScreenerQuery, SortType}; pub use quote::ScreenerQuote; pub use response::ScreenerResults; + +#[cfg(feature = "python")] +pub use quote::PyScreenerQuote; +#[cfg(feature = "python")] +pub use response::PyScreenerResults; pub use values::{ScreenerFundCategory, ScreenerPeerGroup}; diff --git a/src/models/screeners/quote.rs b/src/models/screeners/quote.rs index d1c5b32b..17c793b1 100644 --- a/src/models/screeners/quote.rs +++ b/src/models/screeners/quote.rs @@ -1,6 +1,9 @@ use crate::models::quote::FormattedValue; use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Quote data from a Yahoo Finance screener /// /// This struct contains the fields returned by Yahoo Finance's predefined @@ -8,6 +11,8 @@ use serde::{Deserialize, Serialize}; /// and displaying screened stocks/funds. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] pub struct ScreenerQuote { // Core identification diff --git a/src/models/screeners/response.rs b/src/models/screeners/response.rs index 39d8ee8f..d8a5e454 100644 --- a/src/models/screeners/response.rs +++ b/src/models/screeners/response.rs @@ -1,6 +1,11 @@ use super::quote::ScreenerQuote; use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; +#[cfg(feature = "python")] +use super::quote::PyScreenerQuote; + /// Raw response structure from Yahoo Finance screener API (predefined screeners) /// /// This matches Yahoo's nested response format with finance.result[] wrapper. @@ -68,6 +73,7 @@ struct RawCustomResult { /// /// This removes Yahoo Finance's nested wrapper structure and internal metadata. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct ScreenerResults { /// Array of quotes matching the screener criteria diff --git a/src/models/search/mod.rs b/src/models/search/mod.rs index 718e6ea4..b71c0a5a 100644 --- a/src/models/search/mod.rs +++ b/src/models/search/mod.rs @@ -10,3 +10,6 @@ pub use news::{SearchNews, SearchNewsList}; pub use quote::{SearchQuote, SearchQuotes}; pub use research::{ResearchReport, ResearchReports}; pub use response::SearchResults; + +#[cfg(feature = "python")] +pub use quote::PySearchQuote; diff --git a/src/models/search/news.rs b/src/models/search/news.rs index a54aa97a..cb3a0c11 100644 --- a/src/models/search/news.rs +++ b/src/models/search/news.rs @@ -6,6 +6,11 @@ use super::thumbnail::NewsThumbnail; use serde::{Deserialize, Serialize}; use std::ops::Deref; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; +#[cfg(feature = "python")] +use super::thumbnail::PyNewsThumbnail; + /// A collection of search news with DataFrame support. /// /// This wrapper allows `search_results.news.to_dataframe()` syntax while still @@ -54,6 +59,8 @@ impl SearchNewsList { /// to a DataFrame. Complex fields (thumbnail, related_tickers) are automatically skipped. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[non_exhaustive] #[serde(rename_all = "camelCase")] pub struct SearchNews { diff --git a/src/models/search/quote.rs b/src/models/search/quote.rs index 24d3ba35..6ef589b6 100644 --- a/src/models/search/quote.rs +++ b/src/models/search/quote.rs @@ -5,6 +5,9 @@ use serde::{Deserialize, Serialize}; use std::ops::Deref; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// A collection of search quotes with DataFrame support. /// /// This wrapper allows `search_results.quotes.to_dataframe()` syntax while still @@ -50,6 +53,8 @@ impl SearchQuotes { /// A quote result from symbol search #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[non_exhaustive] #[serde(rename_all = "camelCase")] pub struct SearchQuote { diff --git a/src/models/search/research.rs b/src/models/search/research.rs index 536c901d..0a5f39cf 100644 --- a/src/models/search/research.rs +++ b/src/models/search/research.rs @@ -5,6 +5,9 @@ use serde::{Deserialize, Serialize}; use std::ops::Deref; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// A collection of research reports with DataFrame support. /// /// This wrapper allows `search_results.research_reports.to_dataframe()` syntax while still @@ -50,6 +53,8 @@ impl ResearchReports { /// A research report result from search #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[non_exhaustive] #[serde(rename_all = "camelCase")] pub struct ResearchReport { diff --git a/src/models/search/thumbnail.rs b/src/models/search/thumbnail.rs index fe56c5b7..b5dcd533 100644 --- a/src/models/search/thumbnail.rs +++ b/src/models/search/thumbnail.rs @@ -4,8 +4,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// Thumbnail image with multiple resolutions #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] pub struct NewsThumbnail { /// Available image resolutions pub resolutions: Option>, @@ -13,6 +17,7 @@ pub struct NewsThumbnail { /// Individual thumbnail resolution #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] pub struct ThumbnailResolution { /// Image URL pub url: Option, diff --git a/src/models/sectors/mod.rs b/src/models/sectors/mod.rs index 5111dbbc..2d720781 100644 --- a/src/models/sectors/mod.rs +++ b/src/models/sectors/mod.rs @@ -3,3 +3,9 @@ mod response; pub use response::SectorData; + +#[cfg(feature = "python")] +pub use response::{ + PyResearchReport, PySectorCompany, PySectorData, PySectorETF, PySectorIndustry, + PySectorMutualFund, PySectorOverview, PySectorPerformance, +}; diff --git a/src/models/sectors/response.rs b/src/models/sectors/response.rs index 963c376a..3f010611 100644 --- a/src/models/sectors/response.rs +++ b/src/models/sectors/response.rs @@ -1,6 +1,9 @@ use crate::models::quote::FormattedValue; use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + // ============================================================================ // Raw response structs (private) - for parsing Yahoo's nested structure // ============================================================================ @@ -131,6 +134,7 @@ struct RawResearchReport { /// Complete sector data with all available information #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct SectorData { @@ -184,6 +188,8 @@ pub struct SectorData { /// Sector overview statistics #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct SectorOverview { @@ -215,6 +221,8 @@ pub struct SectorOverview { /// Sector performance metrics #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct SectorPerformance { @@ -242,6 +250,8 @@ pub struct SectorPerformance { /// A company in the sector's top companies list #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct SectorCompany { @@ -284,6 +294,8 @@ pub struct SectorCompany { /// An ETF tracking the sector #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct SectorETF { @@ -314,6 +326,8 @@ pub struct SectorETF { /// A mutual fund in the sector #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct SectorMutualFund { @@ -344,6 +358,8 @@ pub struct SectorMutualFund { /// An industry within the sector #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct SectorIndustry { @@ -374,6 +390,8 @@ pub struct SectorIndustry { /// A research report about the sector #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct ResearchReport { diff --git a/src/models/sentiment/mod.rs b/src/models/sentiment/mod.rs index 35329d25..7e0bf107 100644 --- a/src/models/sentiment/mod.rs +++ b/src/models/sentiment/mod.rs @@ -5,3 +5,6 @@ pub(crate) mod response; pub use response::{FearAndGreed, FearGreedLabel}; + +#[cfg(feature = "python")] +pub use response::{PyFearAndGreed, PyFearGreedLabel}; diff --git a/src/models/sentiment/response.rs b/src/models/sentiment/response.rs index 273a1317..a2bf2ee0 100644 --- a/src/models/sentiment/response.rs +++ b/src/models/sentiment/response.rs @@ -4,6 +4,12 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + +#[cfg(feature = "python")] +pub use py::PyFearGreedLabel; + /// Classification label for the Fear & Greed Index value. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[non_exhaustive] @@ -39,6 +45,7 @@ impl FearGreedLabel { /// /// Scale: 0 (Extreme Fear) → 100 (Extreme Greed). #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[non_exhaustive] pub struct FearAndGreed { /// Index value (0–100) @@ -159,3 +166,48 @@ mod tests { assert!(FearAndGreed::from_response(resp).is_err()); } } + +#[cfg(feature = "python")] +mod py { + use super::FearGreedLabel; + use pyo3::prelude::*; + + + #[pyclass(eq, eq_int, name = "FearGreedLabel")] + #[derive(Clone, Copy, PartialEq, Eq)] + pub enum PyFearGreedLabel { + ExtremeFear, + Fear, + Neutral, + Greed, + ExtremeGreed, + } + + impl ::core::convert::From for FearGreedLabel { + fn from(v: PyFearGreedLabel) -> Self { + match v { + PyFearGreedLabel::ExtremeFear => FearGreedLabel::ExtremeFear, + PyFearGreedLabel::Fear => FearGreedLabel::Fear, + PyFearGreedLabel::Neutral => FearGreedLabel::Neutral, + PyFearGreedLabel::Greed => FearGreedLabel::Greed, + PyFearGreedLabel::ExtremeGreed => FearGreedLabel::ExtremeGreed, + } + } + } + + impl ::core::convert::From for PyFearGreedLabel { + fn from(v: FearGreedLabel) -> Self { + match v { + FearGreedLabel::ExtremeFear => PyFearGreedLabel::ExtremeFear, + FearGreedLabel::Fear => PyFearGreedLabel::Fear, + FearGreedLabel::Neutral => PyFearGreedLabel::Neutral, + FearGreedLabel::Greed => PyFearGreedLabel::Greed, + FearGreedLabel::ExtremeGreed => PyFearGreedLabel::ExtremeGreed, + _ => unreachable!( + "FearGreedLabel is #[non_exhaustive] but all known variants covered" + ), + } + } + } +} + diff --git a/src/models/spark/mod.rs b/src/models/spark/mod.rs index 8b2320b9..96ba15ee 100644 --- a/src/models/spark/mod.rs +++ b/src/models/spark/mod.rs @@ -8,6 +8,12 @@ pub(crate) mod response; use super::chart::ChartMeta; use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + +#[cfg(feature = "python")] +use super::chart::meta::PyChartMeta; + /// Sparkline data for a single symbol. /// /// Contains lightweight chart data optimized for sparkline rendering, @@ -16,6 +22,7 @@ use serde::{Deserialize, Serialize}; /// Note: This struct cannot be manually constructed - obtain via `Tickers::spark()`. #[non_exhaustive] #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "python", derive(PyModel))] #[serde(rename_all = "camelCase")] pub struct Spark { /// Stock symbol diff --git a/src/models/trending/mod.rs b/src/models/trending/mod.rs index 6e6b3a0f..cee2880c 100644 --- a/src/models/trending/mod.rs +++ b/src/models/trending/mod.rs @@ -5,3 +5,6 @@ mod response; pub use response::TrendingQuote; + +#[cfg(feature = "python")] +pub use response::PyTrendingQuote; diff --git a/src/models/trending/response.rs b/src/models/trending/response.rs index 40427869..d5ec2ab5 100644 --- a/src/models/trending/response.rs +++ b/src/models/trending/response.rs @@ -4,9 +4,14 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "python")] +use finance_query_derive::PyModel; + /// A trending stock/symbol quote #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))] +#[cfg_attr(feature = "python", derive(PyModel))] +#[cfg_attr(feature = "python", py_model(dataframe = "columns"))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct TrendingQuote { diff --git a/tools/record-parity-fixtures/Cargo.toml b/tools/record-parity-fixtures/Cargo.toml new file mode 100644 index 00000000..1344574e --- /dev/null +++ b/tools/record-parity-fixtures/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "record-parity-fixtures" +version = "0.1.0" +edition = "2024" +publish = false + +[[bin]] +name = "record-parity" +path = "src/main.rs" + +[dependencies] +finance-query = { path = "../..", features = ["indicators", "risk", "fred", "crypto", "rss", "backtesting", "dataframe"] } +tokio = { version = "1", features = ["full"] } +serde_json = "1" diff --git a/tools/record-parity-fixtures/src/main.rs b/tools/record-parity-fixtures/src/main.rs new file mode 100644 index 00000000..d2585a8d --- /dev/null +++ b/tools/record-parity-fixtures/src/main.rs @@ -0,0 +1,35 @@ +//! Record canonical JSON fixtures for cross-language parity tests. +//! +//! Run: cargo run -p record-parity-fixtures -- AAPL MSFT NVDA TSLA SPY BTC-USD ETH-USD EURUSD=X GBPUSD=X GC=F +//! +//! Output: finance-query-python/tests/parity/fixtures/quote_.json + +use std::fs; +use std::path::PathBuf; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let symbols: Vec = std::env::args().skip(1).collect(); + if symbols.is_empty() { + eprintln!("Usage: record-parity SYM1 [SYM2 ...]"); + std::process::exit(1); + } + + let out_dir = PathBuf::from("finance-query-python/tests/parity/fixtures"); + fs::create_dir_all(&out_dir)?; + + for symbol in &symbols { + eprintln!("recording {}", symbol); + let ticker = finance_query::Ticker::new(symbol.clone()).await?; + let quote = ticker.quote().await?; + + let fname = format!( + "quote_{}.json", + symbol.replace('=', "_").replace('-', "_").to_lowercase() + ); + let path = out_dir.join(fname); + fs::write(&path, serde_json::to_string_pretty("e)?)?; + eprintln!(" wrote {}", path.display()); + } + Ok(()) +}